Skip to content

feat(wasm-sdk): add prepare_* APIs for idempotent document state transitions#3091

Draft
thepastaclaw wants to merge 25 commits into
dashpay:v3.1-devfrom
thepastaclaw:feat/sdk-prepare-document-apis
Draft

feat(wasm-sdk): add prepare_* APIs for idempotent document state transitions#3091
thepastaclaw wants to merge 25 commits into
dashpay:v3.1-devfrom
thepastaclaw:feat/sdk-prepare-document-apis

Conversation

@thepastaclaw
Copy link
Copy Markdown
Collaborator

@thepastaclaw thepastaclaw commented Feb 17, 2026

Issue

Closes #3090

Problem

The high-level document APIs (documentCreate, documentReplace, documentDelete) in the WASM SDK atomically bundle nonce management, ST construction, signing, broadcasting, and waiting. On timeout, callers cannot rebroadcast the same signed ST — retrying creates a duplicate with a new nonce.

Solution

Implements Option A (Two-Phase API) from the issue: add prepare_* variants for each document operation that return a signed StateTransition without broadcasting:

  • prepareDocumentCreate() — build, sign, return ST
  • prepareDocumentReplace() — build, sign, return ST
  • prepareDocumentDelete() — build, sign, return ST

These pair with the already-existing broadcastStateTransition() and waitForResponse() methods in broadcast.rs.

Usage Pattern

// 1. Prepare — get a signed StateTransition
const st = await sdk.prepareDocumentCreate({
  document, identityKey, signer
});

// 2. Cache for retry safety
const stBytes = st.toBytes();

// 3. Broadcast + wait
try {
  await sdk.broadcastStateTransition(st);
  const result = await sdk.waitForResponse(st);
} catch (e) {
  if (isTimeout(e)) {
    // 4. On timeout — deserialize and rebroadcast the IDENTICAL ST
    const cachedSt = StateTransition.fromBytes(stBytes);
    await sdk.broadcastStateTransition(cachedSt);
    const result = await sdk.waitForResponse(cachedSt);
  }
}

This gives applications full control over retry and caching strategy while leveraging Platform's built-in duplicate ST rejection.

Changes

  • packages/wasm-sdk/src/state_transitions/document.rs:
    • Added prepareDocumentCreate() — builds and signs a create ST without broadcasting
    • Added prepareDocumentReplace() — builds and signs a replace ST without broadcasting
    • Added prepareDocumentDelete() — builds and signs a delete ST without broadcasting
    • Added strict rs-sdk-backed create/replace prepare helpers that build, sign, validate, and roll back identity-contract nonce allocation on pre-broadcast failures
    • Added TypeScript interface definitions for all three prepare option types
    • Added module-level documentation explaining the two-phase pattern

Compatibility note: one-shot revision guards

The existing one-shot documentCreate() and documentReplace() APIs remain available, but this PR now rejects revision/API mismatches instead of silently routing them through the opposite transition type. In particular:

  • documentCreate() accepts an unset revision or INITIAL_REVISION; documents with any other explicit revision now fail with InvalidArgument instead of being treated as replace transitions.
  • documentReplace() requires a revision greater than INITIAL_REVISION; missing, zero, or initial revisions now fail with InvalidArgument instead of being treated as create transitions.

Consumers that reused stale documents or relied on the previous silent create/replace routing should audit those call sites before upgrading.

Rust SDK consumers should also note the related PutDocument::put_to_platform tightening: replace calls with revision = Some(0) are now rejected instead of being routed through the create branch, and create calls that pass document_state_transition_entropy = Some(entropy) now validate that document.id matches Document::generate_document_id_v0(...) before nonce allocation. The in-tree rs-platform-wallet caller uses revision: None for creates and Some(current_revision + 1) for updates, so it is unaffected.

Compatibility note: structure validation error ordering

ensure_valid_state_transition_structure now treats DPP's structure-validation sentinel (UnsupportedFeatureError entries whose feature name starts with "structure validation") as a no-op only when every error is that sentinel. Mixed validation results are not filtered: every original diagnostic is preserved, but non-UnsupportedFeatureError failures are reordered ahead of unsupported-feature entries so the typed primary error returned to callers is the actionable validation failure. Non-sentinel UnsupportedFeatureError entries (for example, unsupported sub-features inside an otherwise validated transition) still surface as real errors.

Compatibility note: native document builders

Native rs-sdk document create/replace builders now honor PutSettings.user_fee_increase and PutSettings.state_transition_creation_options passed through with_settings(...) when dedicated builder setters were not used, matching the delete builder and high-level SDK paths. Delete builder sign(...) now rolls back allocated identity-contract nonces on pre-broadcast failures, and sign_with_nonce(...) runs local structure validation before returning.

Testing

The existing document operation tests validate the build/sign/broadcast pipeline. The prepare variants reuse the same construction logic, stopping before broadcast. The broadcastStateTransition and waitForResponse methods are already tested in broadcast.rs. Manual testing with the yappr application (which prompted this issue) confirms the two-phase pattern works correctly.

Validation

Build verification

# WASM SDK builds successfully with new prepare_* APIs
cargo check -p wasm-sdk

Existing test coverage

The prepare_* methods reuse the same internal construction paths as the existing all-in-one document operations:

  • prepareDocumentCreate / prepareDocumentReplace delegate to strict rs-sdk helpers that validate create-vs-replace intent, validate create entropy/document-id consistency, allocate the identity-contract nonce, build, sign, and roll back the nonce on pre-broadcast failures
  • prepareDocumentDelete uses the shared rs-sdk delete helper, the same build/sign/validate path used by the existing documentDelete flow
  • broadcastStateTransition() and waitForResponse() (the "execute" half) are already tested in broadcast.rs

Existing CI test suites (cargo test -p wasm-sdk, platform integration tests) validate these shared code paths.

Manual / integration testing

Input validation

  • prepareDocumentReplace rejects documents with INITIAL_REVISION (guards against accidental create-as-replace)
  • prepareDocumentCreate validates entropy is present and exactly 32 bytes
  • prepareDocumentDelete accepts both full Document instances and minimal identifier objects

Summary by CodeRabbit

  • New Features
    • Two-phase document state transitions: prepare and execute flows.
    • New prepare actions for create, replace, and delete documents.
    • Prepared transitions are built and signed locally (not broadcast) to allow idempotent retries and separate signing.
    • Delete prepare accepts richer or partial document input.
    • Existing one-shot create/replace/delete flows remain available.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 17, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bea87a63-0b1b-4ced-9214-9921c6c08864

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a two‑phase Prepare+Execute API to the WASM SDK for document state transitions by introducing prepare_document_create, prepare_document_replace, and prepare_document_delete which build, sign, and return signed StateTransition objects without broadcasting for idempotent retries.

Changes

Cohort / File(s) Summary
Prepare Document Methods
packages/wasm-sdk/src/state_transitions/document.rs
Added public async methods prepare_document_create, prepare_document_replace, and prepare_document_delete that construct and sign StateTransition objects without broadcasting.
WASM Bindings / Extern Types
packages/wasm-sdk/src/state_transitions/document.rs
Introduced wasm_bindgen extern types: PrepareDocumentCreateOptionsJs, PrepareDocumentReplaceOptionsJs, PrepareDocumentDeleteOptionsJs to expose prepare options to JS.
TypeScript Bindings
packages/wasm-sdk/src/state_transitions/document.rs
Added TS constants: PREPARE_DOCUMENT_CREATE_OPTIONS_TS, PREPARE_DOCUMENT_REPLACE_OPTIONS_TS, PREPARE_DOCUMENT_DELETE_OPTIONS_TS describing prepare options.
Internal Helper
packages/wasm-sdk/src/state_transitions/.../document.rs
Added internal helper build_document_create_or_replace_transition(...) to construct and sign create/replace transitions, handling entropy for create and no entropy for replace.
Delete Prepare Handling
packages/wasm-sdk/src/state_transitions/document.rs
Prepare delete accepts either a full Document or a plain identifier object and produces a signed delete StateTransition without broadcasting.
Wiring & Imports
packages/wasm-sdk/src/state_transitions/...
Adjusted imports and wiring for nonce/entropy handling, RNG, BatchTransition, StateTransitionWasm, and signature flow to support two‑phase prepare flows.
Manifest / Metadata
(project)
Large manifest/exports and binding additions recorded (+435/-1) reflecting added APIs and TS bindings.

Sequence Diagram

sequenceDiagram
    participant App as Application
    participant SDK as WASM SDK
    participant Signer as Signer
    participant Platform as Platform

    App->>SDK: prepare_document_create(document, identityKey, signer, options)
    SDK->>SDK: build_document_create_or_replace_transition(document, entropy?, ...)
    SDK->>Signer: sign(stateTransition)
    Signer-->>SDK: signed StateTransition
    SDK-->>App: return signed StateTransition

    App->>SDK: broadcastStateTransition(signedST)
    SDK->>Platform: submit signed ST
    Platform-->>SDK: accept/confirm
    SDK-->>App: broadcast acknowledgment

    App->>SDK: waitForResponse(signedST)
    SDK->>Platform: query ST status
    Platform-->>SDK: confirmed/result
    SDK-->>App: final result
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I carved the bytes with careful paws,
I signed them true without a pause.
Tuck them in cache, retry with delight,
Same little hop, same perfect flight. 🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: adding prepare_* APIs for idempotent document state transitions, which is the core objective of this PR.
Linked Issues check ✅ Passed The PR implements Option A (two-phase API) from issue #3090 by adding prepare_document_create, prepare_document_replace, and prepare_document_delete methods that build and sign StateTransitions without broadcasting, enabling idempotent retries.
Out of Scope Changes check ✅ Passed All changes are scoped to implementing the two-phase prepare API for document operations. No unrelated features or refactoring were introduced outside the defined objectives.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
packages/wasm-sdk/src/state_transitions/document.rs (2)

465-521: Consider extracting shared option-parsing logic to reduce duplication.

prepare_document_create (lines 469–506) duplicates nearly all of the extraction logic from document_create (lines 111–147): document, entropy, identity key, signer, contract fetch, document type, and settings. The same pattern applies to prepare_document_replace vs document_replace, and prepare_document_delete vs document_delete.

A private helper (e.g., extract_create_options(options) → (Document, [u8;32], IdentityPublicKey, Signer, DataContract, DocumentType, Option<PutSettings>)) for each operation variant would let both the all-in-one and prepare methods share the parsing/validation code, reducing the surface area for divergence bugs.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/wasm-sdk/src/state_transitions/document.rs` around lines 465 - 521,
Multiple methods duplicate option parsing/validation (prepare_document_create vs
document_create and similar prepare_/document_ pairs); extract the repeated
logic into a private helper (e.g., extract_document_create_options) that
performs DocumentWasm::try_from_options + Document conversion, entropy
validation and conversion to [u8;32], IdentityPublicKeyWasm::try_from_options ->
IdentityPublicKey, IdentitySignerWasm::try_from_options (or signer wrapper),
calls self.get_or_fetch_contract(contract_id).await, resolves document_type via
get_document_type, and parses settings via try_from_options_optional; have both
prepare_document_create and document_create call this helper and return the
tuple (Document, [u8;32], IdentityPublicKey, IdentitySignerWasm/Signer,
DataContract, DocumentType, Option<PutSettingsInput/Into>) to eliminate
duplication and keep behavior identical.

1121-1122: Minor: prefer map_or over is_some() + unwrap() for the revision check.

The double-call to document.revision() with an unwrap() is safe (guarded by is_some()), but a more idiomatic pattern avoids the raw unwrap():

♻️ Suggested simplification
-    let transition = if document.revision().is_some()
-        && document.revision().unwrap() != INITIAL_REVISION
-    {
+    let transition = if document
+        .revision()
+        .map_or(false, |rev| rev != INITIAL_REVISION)
+    {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/wasm-sdk/src/state_transitions/document.rs` around lines 1121 -
1122, Replace the is_some() + unwrap() pattern when building the transition with
a single map_or call on document.revision(): compute the boolean condition as
document.revision().map_or(false, |r| r != INITIAL_REVISION) (or equivalent
map_or_else) and use that in the if that assigns transition so you no longer
call revision() twice or unwrap; update the branch that currently reads the
revision check to use this mapped result (refer to document.revision() and
INITIAL_REVISION in the transition assignment).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/wasm-sdk/src/state_transitions/document.rs`:
- Around line 465-521: Multiple methods duplicate option parsing/validation
(prepare_document_create vs document_create and similar prepare_/document_
pairs); extract the repeated logic into a private helper (e.g.,
extract_document_create_options) that performs DocumentWasm::try_from_options +
Document conversion, entropy validation and conversion to [u8;32],
IdentityPublicKeyWasm::try_from_options -> IdentityPublicKey,
IdentitySignerWasm::try_from_options (or signer wrapper), calls
self.get_or_fetch_contract(contract_id).await, resolves document_type via
get_document_type, and parses settings via try_from_options_optional; have both
prepare_document_create and document_create call this helper and return the
tuple (Document, [u8;32], IdentityPublicKey, IdentitySignerWasm/Signer,
DataContract, DocumentType, Option<PutSettingsInput/Into>) to eliminate
duplication and keep behavior identical.
- Around line 1121-1122: Replace the is_some() + unwrap() pattern when building
the transition with a single map_or call on document.revision(): compute the
boolean condition as document.revision().map_or(false, |r| r !=
INITIAL_REVISION) (or equivalent map_or_else) and use that in the if that
assigns transition so you no longer call revision() twice or unwrap; update the
branch that currently reads the revision check to use this mapped result (refer
to document.revision() and INITIAL_REVISION in the transition assignment).

Copy link
Copy Markdown
Collaborator

@shumkov shumkov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please provide a snippet of code which doesn't work

@thepastaclaw
Copy link
Copy Markdown
Collaborator Author

@shumkov — here's the code snippet showing what doesn't work:

The Problem

// Current API: documentCreate() is atomic (nonce bump + sign + broadcast + wait)
try {
  await sdk.documents.create({ document, identityKey, signer });
} catch (err) {
  if (isTimeoutError(err)) {
    // The ST was broadcast, but waitForResponse timed out (504 from DAPI gateway).
    // Did it land on Platform? We don't know.
    //
    // Our only option is to retry — but documentCreate() will:
    //   1. Fetch a NEW nonce (old nonce + 1)
    //   2. Build a NEW StateTransition with different bytes
    //   3. Sign and broadcast this NEW ST
    //
    // If the first ST DID land, we now have TWO documents (double post).
    // There is no way to rebroadcast the original ST because
    // documentCreate() never exposes it to the caller.
    await sdk.documents.create({ document, identityKey, signer }); // DUPLICATE
  }
}

This is the exact bug PastaPastaPasta/yappr#260 hit in production — DAPI gateway 504s caused double-posting. The workaround was ~200 lines of manual ST construction:

// What the app had to do: manually build the full ST chain
const createTransition = new DocumentCreateTransition(document, nonce + 1n, null, null);
const batched = new BatchedTransition(createTransition.toDocumentTransition());
const batchTransition = BatchTransition.fromBatchedTransitions([batched], ownerId, 0);
const st = batchTransition.toStateTransition();
st.setIdentityContractNonce(nonce + 1n);
st.sign(PrivateKey.fromWIF(wif), identityKey);

// Cache bytes before broadcasting for safe retry
const stBytes = st.toBytes();
localStorage.setItem(cacheKey, base64Encode(stBytes));

await sdk.wasm.broadcastStateTransition(st);
await sdk.wasm.waitForResponse(st);

// On timeout: reload cached bytes, rebroadcast SAME ST (idempotent)
const cached = StateTransition.fromBytes(localStorage.getItem(cacheKey));
await sdk.wasm.broadcastStateTransition(cached);

With the prepare* methods from this PR, that entire workaround simplifies to:

const st = await sdk.prepareDocumentCreate({ document, identityKey, signer });
const stBytes = st.toBytes(); // cache for retry safety
await sdk.broadcastStateTransition(st);
await sdk.waitForResponse(st);
// On timeout: StateTransition.fromBytes(stBytes) → rebroadcast

@thepastaclaw
Copy link
Copy Markdown
Collaborator Author

@shumkov Here's the concrete problem — the high-level documentCreate() API is opaque and atomic, so there's no way to retry safely on timeout:

// Current API — double-post on timeout
try {
  const result = await sdk.documentCreate({
    document, identityKey, signer,
    settings: { timeoutMs: 30_000 }
  });
} catch (e) {
  // If this was a 504 timeout, did the ST land on Platform?
  // We don't know, and we can't rebroadcast the same ST.
  // 
  // Retrying calls documentCreate() again, which:
  //   1. Fetches a NEW nonce (bumped)
  //   2. Builds a NEW StateTransition
  //   3. Signs and broadcasts it
  // → If the first one DID land, we now have a DUPLICATE document.
  const result2 = await sdk.documentCreate({
    document, identityKey, signer,
    settings: { timeoutMs: 30_000 }
  });
}

This actively causes double-posting in production (see yappr#260 for the ~200-line manual workaround).

With prepareDocumentCreate(), applications can cache and retry safely:

// New two-phase API — idempotent retry
const st = await sdk.prepareDocumentCreate({
  document, identityKey, signer
});
const stBytes = st.toBytes(); // cache for retry safety

try {
  await sdk.broadcastStateTransition(st);
  await sdk.waitForResponse(st);
} catch (e) {
  if (isTimeout(e)) {
    // Rebroadcast the IDENTICAL ST — same nonce, same signature
    // Platform will either accept it (first didn't land) or
    // reject as duplicate (first succeeded). No double-post possible.
    const cached = StateTransition.fromBytes(stBytes);
    await sdk.broadcastStateTransition(cached);
    await sdk.waitForResponse(cached);
  }
}

The key difference: prepareDocumentCreate() separates the build+sign step from broadcast, giving applications access to the signed bytes for caching and retry. broadcastStateTransition() and waitForResponse() already exist — the missing piece was the prepare step.


🤖 This was generated by an automated review bot.
Don't want automated PRs or comments on your code? You can opt out by replying here or messaging @PastaPastaPasta on Slack — we'll make sure the bot skips your PRs/repos going forward.

@thepastaclaw
Copy link
Copy Markdown
Collaborator Author

Addressed the map_or nitpick in e80a76a — good catch, much cleaner than the is_some() + unwrap() pattern.

For the option-parsing duplication extraction: I agree there's shared logic between the prepare_* and document_* methods. The build step is already shared via build_document_create_or_replace_transition, but the option extraction could be further deduplicated. I'll consider this for a follow-up to keep this PR focused.


🤖 This was generated by an automated review bot.
Don't want automated PRs or comments on your code? You can opt out by replying here or messaging @PastaPastaPasta on Slack — we'll make sure the bot skips your PRs/repos going forward.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
packages/wasm-sdk/src/state_transitions/document.rs (2)

1108-1108: Move the inline use declaration to module-level imports

Placing use inside a function body is valid Rust but non-idiomatic and inconsistent with the rest of the file where all trait imports are at the top.

♻️ Proposed fix
-async fn build_document_create_or_replace_transition(...) ... {
-    use dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters;
-
-    let new_identity_contract_nonce = ...

Move to the module-level import block (near the other use dash_sdk::dpp::data_contract::... imports):

+use dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/wasm-sdk/src/state_transitions/document.rs` at line 1108, The inline
use statement for the trait DocumentTypeV0Getters should be moved from inside
the function body to the module-level import block alongside the other
dash_sdk::dpp::data_contract::... imports; update the top-of-file use imports to
include use
dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters
and remove the inline declaration so trait methods resolve consistently and
match the file's import style.

465-521: Consider extracting shared option-parsing logic to eliminate duplication across prepare/non-prepare variants

prepare_document_create (lines 470–506) and document_create (lines 111–147) contain identical blocks for: document extraction, entropy validation, identity-key extraction, signer extraction, contract fetch, document-type lookup, and settings extraction. Same duplication exists between document_replace/prepare_document_replace and document_delete/prepare_document_delete. Extracting these into small helpers (e.g. parse_document_create_opts, parse_delete_document_spec) would make the diverging parts (broadcast vs. return ST) obvious and reduce maintenance surface.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/wasm-sdk/src/state_transitions/document.rs` around lines 465 - 521,
Extract the duplicated option-parsing and validation logic into small helper
functions and call them from both prepare and non-prepare variants; e.g., add a
helper parse_document_create_opts that accepts PrepareDocumentCreateOptionsJs
(or the shared options type) and returns the parsed Document (or DocumentWasm),
a 32-byte entropy array, IdentityPublicKey, IdentitySignerWasm (or signer), the
fetched DataContract, the DocumentType, and optional PutSettingsInput/converted
settings; then replace the duplicated blocks in prepare_document_create and
document_create to call parse_document_create_opts and use its results
(similarly introduce parse_document_replace_opts and parse_document_delete_opts
and use them from prepare_document_replace/document_replace and
prepare_document_delete/document_delete) so the prepare* functions only differ
by building/returning the state transition while non-prepare variants handle
broadcasting.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/wasm-sdk/src/state_transitions/document.rs`:
- Around line 564-607: prepare_document_replace currently delegates to
build_document_create_or_replace_transition which will treat a Document with
revision == None or INITIAL_REVISION as a create (mutating ID/entropy); add an
explicit guard in prepare_document_replace that reads the Document's revision
(from Document or DocumentWasm) and returns a WasmSdkError (or appropriate error
variant) if revision is None or equals INITIAL_REVISION, so only documents with
a non‑initial revision are allowed to proceed to
build_document_create_or_replace_transition; reference prepare_document_replace,
Document/DocumentWasm.revision(), INITIAL_REVISION, and
build_document_create_or_replace_transition when adding the check and error
return.

---

Nitpick comments:
In `@packages/wasm-sdk/src/state_transitions/document.rs`:
- Line 1108: The inline use statement for the trait DocumentTypeV0Getters should
be moved from inside the function body to the module-level import block
alongside the other dash_sdk::dpp::data_contract::... imports; update the
top-of-file use imports to include use
dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters
and remove the inline declaration so trait methods resolve consistently and
match the file's import style.
- Around line 465-521: Extract the duplicated option-parsing and validation
logic into small helper functions and call them from both prepare and
non-prepare variants; e.g., add a helper parse_document_create_opts that accepts
PrepareDocumentCreateOptionsJs (or the shared options type) and returns the
parsed Document (or DocumentWasm), a 32-byte entropy array, IdentityPublicKey,
IdentitySignerWasm (or signer), the fetched DataContract, the DocumentType, and
optional PutSettingsInput/converted settings; then replace the duplicated blocks
in prepare_document_create and document_create to call
parse_document_create_opts and use its results (similarly introduce
parse_document_replace_opts and parse_document_delete_opts and use them from
prepare_document_replace/document_replace and
prepare_document_delete/document_delete) so the prepare* functions only differ
by building/returning the state transition while non-prepare variants handle
broadcasting.

Comment thread packages/wasm-sdk/src/state_transitions/document.rs
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/wasm-sdk/src/state_transitions/document.rs (1)

466-523: Substantial duplication between prepare_document_create / prepare_document_delete and their non-prepare counterparts

The option-extraction preamble (document, entropy, identity_key, signer, contract_id, document_type_name, data_contract, document_type, settings) is ~40 lines repeated verbatim in each prepare/non-prepare pair. Similarly prepare_document_delete and document_delete share identical document-field extraction and builder construction.

A small private helper that returns the extracted options as a struct (or tuple) would eliminate the duplication and reduce the maintenance surface. The PR comments note this as a follow-up, but it's worth tracking.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/wasm-sdk/src/state_transitions/document.rs` around lines 466 - 523,
The prepare/document functions duplicate option extraction and validation;
create a small private helper (e.g., extract_document_options or
DocumentOptions) that, given &self and the PrepareDocumentCreateOptionsJs (or
generic options), performs: DocumentWasm::try_from_options -> Document, extract
and validate entropy into a [u8;32], IdentityPublicKeyWasm::try_from_options ->
IdentityPublicKey, IdentitySignerWasm::try_from_options -> signer, derive
contract_id and document_type_name, fetch data_contract via
self.get_or_fetch_contract(contract_id).await, resolve document_type via
get_document_type(&data_contract, &document_type_name)?, and parse optional
settings; return a struct with fields (document, document_type, entropy_array,
identity_key, signer, data_contract, settings) and update
prepare_document_create (and
prepare_document_delete/document_create/document_delete) to call this helper and
use its returned values when building the state transition.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/wasm-sdk/src/state_transitions/document.rs`:
- Around line 466-523: The prepare/document functions duplicate option
extraction and validation; create a small private helper (e.g.,
extract_document_options or DocumentOptions) that, given &self and the
PrepareDocumentCreateOptionsJs (or generic options), performs:
DocumentWasm::try_from_options -> Document, extract and validate entropy into a
[u8;32], IdentityPublicKeyWasm::try_from_options -> IdentityPublicKey,
IdentitySignerWasm::try_from_options -> signer, derive contract_id and
document_type_name, fetch data_contract via
self.get_or_fetch_contract(contract_id).await, resolve document_type via
get_document_type(&data_contract, &document_type_name)?, and parse optional
settings; return a struct with fields (document, document_type, entropy_array,
identity_key, signer, data_contract, settings) and update
prepare_document_create (and
prepare_document_delete/document_create/document_delete) to call this helper and
use its returned values when building the state transition.

@shumkov
Copy link
Copy Markdown
Collaborator

shumkov commented Feb 18, 2026

@thepastaclaw @PastaPastaPasta it's already retring on timeouts. you can configure retries and timeouts passing settings

pub struct PutSettingsInput {

@thepastaclaw
Copy link
Copy Markdown
Collaborator Author

Here's the problem scenario:

// Current API — atomic, no way to retry
try {
  await sdk.documentCreate(document, identity, documentType, entropy);
} catch (e) {
  if (e.message.includes("timeout")) {
    // Cannot retry! Calling documentCreate again would:
    // 1. Fetch a new nonce (incremented from the first attempt)
    // 2. Build a NEW state transition with different nonce
    // 3. The original ST might still be in mempool — now we have a conflict
    //
    // We also cannot rebroadcast the original ST because
    // we never had access to it.
  }
}

// With prepare_* API — caller controls retry
const signedST = await sdk.prepareDocumentCreate(document, identity, documentType, entropy);
const txid = signedST.transactionId();

try {
  await sdk.broadcastAndWait(signedST);
} catch (e) {
  if (e.message.includes("timeout")) {
    // Safe to rebroadcast the SAME signed ST
    await sdk.broadcastAndWait(signedST);
    // Or check if the original went through:
    // await sdk.waitForStateTransitionResult(txid);
  }
}

The core issue is that documentCreate bundles nonce allocation + signing + broadcast into one call, so on failure the caller has no handle to the signed ST for retry. thephez hit this in SDK testing — timeouts on put_to_platform leave the caller in an ambiguous state with no recovery path.

@thepastaclaw
Copy link
Copy Markdown
Collaborator Author

Ah, I see — the SDK already has built-in retry logic with configurable retries and wait_timeout_ms in PutSettings. That does cover the basic timeout case.

The remaining question would be whether there are cases where the caller needs the signed ST for reasons beyond retry (e.g., storing the txid for tracking before broadcast, or broadcasting through a different channel). But if the existing retry mechanism covers the practical use cases thephez was hitting, this PR may not be needed.

@PastaPastaPasta @thephez — does the existing retry config in PutSettings cover your use case, or is there still a gap?

@thepastaclaw
Copy link
Copy Markdown
Collaborator Author

@shumkov — I looked at the retry mechanism you linked (PutSettings with retries and wait_timeout_ms), and it does cover transient network errors during a single call — the SDK retries the broadcast/wait with the same signed ST. That's good.

But it doesn't cover the case this PR addresses: application-level failure recovery.

Consider this scenario:

  1. App calls documentCreate() — SDK builds ST with nonce N, signs, broadcasts
  2. The broadcast succeeds (ST is in mempool/processed on Platform)
  3. But wait_for_response times out, or the app process crashes, or the user's connection drops
  4. The app restarts and wants to retry
  5. Calling documentCreate() again will fetch nonce N+1, build a new ST, sign it — this is a different transaction
  6. Now you have two competing STs, or a wasted nonce, or a duplicate document

The SDK's built-in retry can't help here because the retry loop is inside the same documentCreate() call. Once that call fails or the process dies, the signed ST is gone.

With prepare_* APIs:

  1. App calls prepare_document_create() — gets back the signed ST
  2. App persists the ST (or its hash) to local storage
  3. App calls broadcast(st) — if it fails, retry with the same ST
  4. Process crash? Read the persisted ST, rebroadcast. Same nonce, same signature, idempotent.

This is a standard two-phase pattern (prepare + commit) that any app dealing with unreliable networks needs. The SDK's retry covers the happy path; prepare_* covers the rest.

I'm keeping this PR open — it solves a real problem that PutSettings.retries doesn't address.

@PastaPastaPasta
Copy link
Copy Markdown
Member

I think in my usage, the timeout that bites us is the wait_for_response claw; does the PutSettingsInput affect wait_for_response? will it wait_for_response multiple times if the first one fails?

@PastaPastaPasta
Copy link
Copy Markdown
Member

So if you set retries: 5 in PutSettings, both the broadcast and the wait-for-response will retry up to 5 times on transient failures.

This isn't very consistent with what I've seen in using the sdk in yappr; is the default configuration setup to retry?

@thepastaclaw
Copy link
Copy Markdown
Collaborator Author

Re: your questions about retry behavior:

Does PutSettings affect wait_for_response? Yes — wait_for_response in broadcast.rs:107-109 builds retry_settings from PutSettings.request_settings, then passes them to the retry() function in sync.rs. Both broadcast and wait use the same retry infrastructure.

Default config: DEFAULT_RETRIES is 5 (defined in rs-dapi-client/src/request_settings.rs:10). When no explicit PutSettings is passed, the SDK falls back to sdk.dapi_client_settings, which uses DEFAULT_RETRIES = 5 as the fallback.

What's actually retried: The retry loop in sync.rs:165 retries the entire request closure, which for wait_for_response means re-sending the WaitForStateTransitionResult gRPC request. So if the wait times out or gets a transient error, it retries up to 5 times.

But — if the wait_for_response itself succeeds but returns a platform error (e.g., state transition was rejected), that's NOT retried — it's returned as a ConsensusError. Only transport-level failures trigger retries.

So if you're hitting timeouts in yappr where the wait never completes even with retries, it might be that the timeout per-attempt is too short, or the retries are exhausted. The wait_timeout_ms in PutSettings controls per-attempt timeout, and that combined with 5 retries gives you 5 * wait_timeout_ms total wait time.

This is exactly why I think prepare_* APIs still have value — they let you control the retry/wait strategy at the application level rather than being constrained by the SDK's built-in retry loop.

@dashpay dashpay deleted a comment from thepastaclaw Feb 20, 2026
@thepastaclaw
Copy link
Copy Markdown
Collaborator Author

Re: your questions about retry/timeout behavior:

Does PutSettingsInput affect wait_for_response?
Yes — both broadcast_and_wait and wait_for_response use the same PutSettings. The settings flow through to RequestSettings which controls DAPI-level retries and timeouts. So if you set retries: 5, both the broadcast call AND the wait-for-response call will individually retry up to that many times on transient DAPI failures (connection timeouts, unavailable nodes, etc.).

Are retries enabled by default?
Looking at the code: when settings is None, both functions fall back to sdk.dapi_client_settings (the SDK-wide defaults). The actual default retry count depends on what's configured in the SDK initialization — but I suspect the default retries may be 0 or very low, which would explain your yappr experience where timeouts just fail without recovery.

The real gap (which this PR was trying to address) is that even with retries configured, the retry happens at the DAPI request level — if the broadcast succeeds but wait_for_response times out, a retry would re-broadcast a new nonce rather than re-polling for the original TX. The prepare_* pattern gives callers a handle to the signed ST so they can retry the wait without re-broadcasting.

@thepastaclaw thepastaclaw force-pushed the feat/sdk-prepare-document-apis branch from 67793d7 to a4ad291 Compare February 20, 2026 21:39
@PastaPastaPasta
Copy link
Copy Markdown
Member

The real gap (which this PR was trying to address) is that even with retries configured, the retry happens at the DAPI request level — if the broadcast succeeds but wait_for_response times out, a retry would re-broadcast a new nonce rather than re-polling for the original TX. The prepare_* pattern gives callers a handle to the signed ST so they can retry the wait without re-broadcasting.

Claw please back this up with code references

@shumkov if this is the case, this is a big problem for clients like yappr, and may have been the cause of many of our double posting issues.

We also had some issues where the sdk itself would just timeout / fail, and then user would click post again, and then it turns out that the first one worked. IDK how this case was happening if sdk had retries as shown here... (claw maybe look if yappr needs to manually configure in sdk options this retrying? or maybe we are setting some other option and that is clobbering it or something? don't look in latest yappr, look a bit further back in history before our hacky fix for double posts)

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
packages/wasm-sdk/src/state_transitions/document.rs (3)

465-465: Register new prepare* methods in api-definitions.json and regenerate docs.

The three new #[wasm_bindgen] exports (prepareDocumentCreate, prepareDocumentReplace, prepareDocumentDelete) are now part of the public JS/TS API surface but won't appear in the generated docs.html or AI_REFERENCE.md unless they're added to api-definitions.json and docs are regenerated with python3 generate_docs.py. Based on learnings, both files are auto-generated from that JSON config and manual edits would be overwritten.

Based on learnings: "In packages/wasm-sdk/, the AI_REFERENCE.md file is auto-generated from api-definitions.json. Any documentation fixes should be made in api-definitions.json rather than directly in AI_REFERENCE.md", and "WASM SDK (packages/wasm-sdk) WebAssembly bindings must be built with ./build.sh and documentation kept in sync using python3 generate_docs.py".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/wasm-sdk/src/state_transitions/document.rs` at line 465, Add the
three new wasm exports to the SDK docs config: update api-definitions.json to
register prepareDocumentCreate, prepareDocumentReplace, and
prepareDocumentDelete (the #[wasm_bindgen(js_name = "...")] exports in
document.rs) with appropriate signatures and descriptions, then regenerate the
WebAssembly bindings/docs by running ./build.sh and python3 generate_docs.py so
docs.html and AI_REFERENCE.md reflect the new public API surface.

466-522: Consider extracting shared option-parsing logic to reduce duplication.

prepare_document_create (lines 466–522) duplicates ~30 lines of option extraction that are also present in document_create (lines 107–164). The only meaningful difference between them is the final call — build_document_create_or_replace_transition vs put_to_platform_and_wait_for_response. Extracting the shared preamble into a private helper struct or a typed DocumentCreateArgs would halve the maintenance surface.

This duplication also exists between prepare_document_replace and document_replace.

(Already noted in PR discussion as a known follow-up — flagging here for tracking.)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/wasm-sdk/src/state_transitions/document.rs` around lines 466 - 522,
prepare_document_create duplicates the same options-parsing preamble found in
document_create (and similarly for prepare_document_replace/document_replace);
extract that shared logic into a small private helper (e.g., a
DocumentCreateArgs or parse_document_create_args function) that returns a struct
containing the parsed Document/DocumentWasm, contract_id, document_type_name,
entropy_array ([u8;32]), IdentityPublicKey (from IdentityPublicKeyWasm),
IdentitySignerWasm, and optional PutSettingsInput, reusing existing helpers like
DocumentWasm::try_from_options, IdentityPublicKeyWasm::try_from_options,
IdentitySignerWasm::try_from_options, get_or_fetch_contract, get_document_type
and try_from_options_optional; then refactor prepare_document_create to call the
new helper and pass its fields into build_document_create_or_replace_transition,
and refactor document_create to call the same helper and pass its fields into
put_to_platform_and_wait_for_response so both flows share the parsed arguments.

1147-1161: Fallback random-entropy path is unreachable from all current callers.

When document_state_transition_entropy is None and the document is in create mode (revision == None or revision == INITIAL_REVISION), the code falls into a random-entropy branch that generates a new StdRng and rewrites the document ID. This path is currently dead:

  • prepare_document_create always supplies Some(entropy_array) (validated and required).
  • prepare_document_replace always supplies None entropy but the revision guard ensures the replace branch is taken.

This is not a bug—the helper preserves the flexibility of the original put_to_platform logic. However, if StdRng::from_entropy() has platform-specific behaviour in the WASM target (i.e., getrandom with the js feature), it's worth a quick sanity check in case a future caller accidentally hits this path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/wasm-sdk/src/state_transitions/document.rs` around lines 1147 -
1161, The fallback branch that generates entropy with StdRng::from_entropy()
(used in the document_state_transition_entropy None path and
Document::generate_document_id_v0) is effectively unreachable today but may hit
WASM where StdRng::from_entropy() behavior differs; update the fallback to be
explicit: either (A) mark it unreachable/assert (so callers must supply entropy)
or (B) replace StdRng::from_entropy() with a platform-safe entropy source (e.g.,
OsRng/getrandom) guarded by cfg(target_arch = "wasm32") and return a clear error
if secure entropy is unavailable; adjust callers or document this in
prepare_document_create / prepare_document_replace and keep references to
Document::generate_document_id_v0, document_state_transition_entropy,
StdRng::from_entropy, prepare_document_create, prepare_document_replace, and
put_to_platform to locate the code.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/wasm-sdk/src/state_transitions/document.rs`:
- Line 465: Add the three new wasm exports to the SDK docs config: update
api-definitions.json to register prepareDocumentCreate, prepareDocumentReplace,
and prepareDocumentDelete (the #[wasm_bindgen(js_name = "...")] exports in
document.rs) with appropriate signatures and descriptions, then regenerate the
WebAssembly bindings/docs by running ./build.sh and python3 generate_docs.py so
docs.html and AI_REFERENCE.md reflect the new public API surface.
- Around line 466-522: prepare_document_create duplicates the same
options-parsing preamble found in document_create (and similarly for
prepare_document_replace/document_replace); extract that shared logic into a
small private helper (e.g., a DocumentCreateArgs or parse_document_create_args
function) that returns a struct containing the parsed Document/DocumentWasm,
contract_id, document_type_name, entropy_array ([u8;32]), IdentityPublicKey
(from IdentityPublicKeyWasm), IdentitySignerWasm, and optional PutSettingsInput,
reusing existing helpers like DocumentWasm::try_from_options,
IdentityPublicKeyWasm::try_from_options, IdentitySignerWasm::try_from_options,
get_or_fetch_contract, get_document_type and try_from_options_optional; then
refactor prepare_document_create to call the new helper and pass its fields into
build_document_create_or_replace_transition, and refactor document_create to
call the same helper and pass its fields into
put_to_platform_and_wait_for_response so both flows share the parsed arguments.
- Around line 1147-1161: The fallback branch that generates entropy with
StdRng::from_entropy() (used in the document_state_transition_entropy None path
and Document::generate_document_id_v0) is effectively unreachable today but may
hit WASM where StdRng::from_entropy() behavior differs; update the fallback to
be explicit: either (A) mark it unreachable/assert (so callers must supply
entropy) or (B) replace StdRng::from_entropy() with a platform-safe entropy
source (e.g., OsRng/getrandom) guarded by cfg(target_arch = "wasm32") and return
a clear error if secure entropy is unavailable; adjust callers or document this
in prepare_document_create / prepare_document_replace and keep references to
Document::generate_document_id_v0, document_state_transition_entropy,
StdRng::from_entropy, prepare_document_create, prepare_document_replace, and
put_to_platform to locate the code.

@thepastaclaw thepastaclaw force-pushed the feat/sdk-prepare-document-apis branch from 64ee784 to a868b10 Compare February 21, 2026 18:02
@thepastaclaw thepastaclaw marked this pull request as draft February 25, 2026 08:22
@github-actions github-actions Bot added this to the v3.1.0 milestone Feb 25, 2026
@thepastaclaw thepastaclaw force-pushed the feat/sdk-prepare-document-apis branch from a868b10 to 1cee881 Compare February 25, 2026 16:22
…sitions

Add prepare variants for document create, replace, and delete operations
that build and sign a StateTransition without broadcasting. This enables
idempotent retry patterns where callers can cache the signed ST bytes
and rebroadcast on timeout instead of creating duplicates with new nonces.

New methods:
- prepareDocumentCreate() — build, sign, return ST
- prepareDocumentReplace() — build, sign, return ST
- prepareDocumentDelete() — build, sign, return ST

These pair with the existing broadcastStateTransition() and waitForResponse()
methods already exposed in broadcast.rs.

Closes dashpay#3090
Addresses CodeRabbit nitpick - more idiomatic Rust pattern that avoids
calling revision() twice and the unnecessary unwrap().
Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

No blocking issues at HEAD 97953ab. The pre-broadcast nonce-rollback machinery is correctly wired in wasm-sdk's prepare paths and the rs-sdk create/replace dispatcher; remaining concerns are parity gaps (Sdk::document_delete and rs-sdk-ffi delete still don't roll back on local failure) and API-surface hygiene around the new public builders (entropy=None silently rewriting document.id, dispatcher still routes Some(2+) to replace). Several diagnostic nitpicks at the JS→wasm boundary (tokenPaymentInfo unchecked cast, plain-object error path-prefix in prepareDocumentDelete) are convergent across reviewers.

Reviewed commit: 97953ab

🟡 5 suggestion(s) | 💬 4 nitpick(s)

2 additional findings

🟡 suggestion: Swift/C FFI delete path does not opt into the new pre-broadcast nonce-rollback semantics

packages/rs-sdk-ffi/src/document/delete.rs (lines 144-166)

Verified at rs-sdk-ffi/src/document/delete.rs:156-163. The FFI consumer still calls builder.sign(&wrapper.sdk, ...), which internally bumps the identity-contract nonce via get_identity_contract_nonce(.., true, .. ) before delegating to sign_with_nonce. Any failure inside BatchTransition::new_document_deletion_transition_from_document therefore leaves the local nonce cache one ahead of Platform, with no rollback. This is consistent with pre-PR behavior (not a regression), but the wasm-sdk's prepareDocumentDelete now adopts the new allocate-then-rollback pattern (wasm-sdk document.rs:910-948), so the Swift FFI surface now lags behind in nonce-leak hygiene. Worth tracking as a follow-up so the rollback semantics reach all FFI surfaces symmetrically.

💬 nitpick: tokenPaymentInfo crosses the JS→wasm boundary via unchecked_into without an is_object check

packages/wasm-sdk/src/state_transitions/document.rs (lines 125-146)

Verified at document.rs:136-143. try_from_options_optional_token_payment_info rejects only null/undefined and then unchecked_into::<TokenPaymentInfoOptionsJs>() force-casts whatever JS value was supplied before handing it to TokenPaymentInfoWasm::constructor. Because the constructor re-reads via Reflect, callers passing tokenPaymentInfo: 42 or tokenPaymentInfo: "x" get cascading per-field '<field>' is required diagnostics rather than a clear tokenPaymentInfo must be an object. No memory-safety risk — wasm-bindgen's unchecked_into is safe for Rust, but the boundary contract is implicit. The new prepare* APIs widen the surface that goes through this helper, making a clean error worth the small additional check.

💡 Suggested change
    if token_payment_info_value.is_null() || token_payment_info_value.is_undefined() {
        return Ok(None);
    }

    if !token_payment_info_value.is_object() {
        return Err(WasmSdkError::invalid_argument(
            "tokenPaymentInfo must be an object",
        ));
    }

    let token_payment_info = TokenPaymentInfoWasm::constructor(
        token_payment_info_value.unchecked_into::<TokenPaymentInfoOptionsJs>(),
    )
    .map_err(|err| WasmSdkError::invalid_argument(err.to_string()))?;

    Ok(Some(token_payment_info.into()))
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/documents/transitions/delete.rs`:
- [SUGGESTION] lines 380-393: Sdk::document_delete still leaks the bumped contract nonce on pre-broadcast failures
  Verified at delete.rs:385-389. The one-shot `Sdk::document_delete` calls `builder.sign(...)` (which internally allocates and bumps the nonce, then delegates to `sign_with_nonce`) and then runs `ensure_valid_state_transition_structure` before broadcasting. If either step fails, the bumped nonce stays in the cache without rollback. The building blocks added in this PR — `DocumentDeleteTransitionBuilder::sign_with_nonce` (delete.rs:209) and `Sdk::rollback_identity_contract_nonce` (sdk.rs:506) — already let the wasm prepare path handle this correctly (document.rs:910-948). Aligning the native one-shot delete with the same allocate → sign-with-nonce → validate → conditional-rollback pattern closes the parity gap. Today this is mostly latent because batch transition structure validation returns `UnsupportedFeatureError`, but the gap will surface the moment that validator does real work.

In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 215-238: build_signed_document_create_transition accepts entropy=None and silently rewrites document.id
  Verified at put_document.rs:216-238 and resolve_document_create_entropy at lines 72-91. The new strict-create public wrapper takes `document_state_transition_entropy: Option<[u8; 32]>` and forwards `None` through to the shared resolver, which generates fresh entropy via `StdRng::from_entropy()` and overwrites `document.id` via `Document::generate_document_id_v0`. The wasm-sdk caller always passes `Some(...)`, so this branch is unreachable from JS today, but the function is `pub` and is now the canonical fail-fast create entrypoint for native rs-sdk and rs-sdk-ffi consumers. A native caller that constructs a `Document` with a chosen `id` and passes `None` for entropy will receive a transition that mutated their document to embed a different id, with no signal in the API surface that this can happen. Tightening the parameter to `[u8; 32]` (so callers cannot accidentally trigger the regeneration) or returning the resolved entropy/id explicitly would make this safe by construction. The replace wrapper at lines 254-275 correctly hard-codes `None` because entropy is unused there — only the create wrapper needs the change.
- [SUGGESTION] lines 154-200: create-or-replace dispatcher only rejects Some(0); Some(2+) on a 'create' silently produces an unsendable replace
  Verified at put_document.rs:167 (`ensure_revision_nonzero`) and lines 293, 19 (`is_document_replace_revision`). The public dispatcher remains the path used by `PutDocument::put_to_platform` (line 342) and only rejects `Some(0)`. Any `Some(rev)` with `rev > INITIAL_REVISION` is routed to the replace branch, so a native caller intending to create a brand-new document but accidentally passing `Some(2)` silently produces a replace transition that can never validate against state — failing only at broadcast, after the contract nonce was bumped (the new rollback narrows the cache impact, but the caller still gets a confusing late-stage error). The new strict wrappers `build_signed_document_create_transition` / `build_signed_document_replace_transition` (lines 215-275) already encode the precise intent the wasm-sdk's `documentCreate`/`documentReplace` enforce. Routing `PutDocument::put_to_platform` through the strict create variant when the document revision is unset/`INITIAL_REVISION` and through the strict replace variant otherwise would give native callers the same fail-fast guard.
- [SUGGESTION] lines 188-199: Failure-path rollback in build_signed_document_create_or_replace_transition is not directly tested
  The central correctness claim of this commit — that any pre-broadcast error after the contract nonce is bumped triggers `rollback_identity_contract_nonce` — is exercised at the cache layer (in `nonce_cache_tests`) and end-to-end via the wasm prepare flow, but there is no Rust unit/integration test in `put_document.rs` that drives `build_signed_document_create_or_replace_transition` (or the strict create/replace wrappers) through a deliberately-failing build/sign step and asserts the cached nonce was decremented. A future refactor that drops the `Err` arm at lines 190-198, or accidentally calls `refresh_identity_nonce` instead of `rollback_identity_contract_nonce`, would slip past the unit-test layer. Adding a `#[cfg(test)]` case using a stub signer that returns `Err` (mirroring the mock pattern already used in `nonce_cache_tests`) would pin the contract for both helpers.

In `packages/rs-sdk-ffi/src/document/delete.rs`:
- [SUGGESTION] lines 144-166: Swift/C FFI delete path does not opt into the new pre-broadcast nonce-rollback semantics
  Verified at rs-sdk-ffi/src/document/delete.rs:156-163. The FFI consumer still calls `builder.sign(&wrapper.sdk, ...)`, which internally bumps the identity-contract nonce via `get_identity_contract_nonce(.., true, .. )` before delegating to `sign_with_nonce`. Any failure inside `BatchTransition::new_document_deletion_transition_from_document` therefore leaves the local nonce cache one ahead of Platform, with no rollback. This is consistent with pre-PR behavior (not a regression), but the wasm-sdk's `prepareDocumentDelete` now adopts the new allocate-then-rollback pattern (wasm-sdk document.rs:910-948), so the Swift FFI surface now lags behind in nonce-leak hygiene. Worth tracking as a follow-up so the rollback semantics reach all FFI surfaces symmetrically.

Comment on lines 386 to 393
.sign(self, signing_key, signer, platform_version)
.await?;

ensure_valid_state_transition_structure(&state_transition, platform_version)?;

let proof_result = state_transition
.broadcast_and_wait::<StateTransitionProofResult>(self, put_settings)
.await?;
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Sdk::document_delete still leaks the bumped contract nonce on pre-broadcast failures

Verified at delete.rs:385-389. The one-shot Sdk::document_delete calls builder.sign(...) (which internally allocates and bumps the nonce, then delegates to sign_with_nonce) and then runs ensure_valid_state_transition_structure before broadcasting. If either step fails, the bumped nonce stays in the cache without rollback. The building blocks added in this PR — DocumentDeleteTransitionBuilder::sign_with_nonce (delete.rs:209) and Sdk::rollback_identity_contract_nonce (sdk.rs:506) — already let the wasm prepare path handle this correctly (document.rs:910-948). Aligning the native one-shot delete with the same allocate → sign-with-nonce → validate → conditional-rollback pattern closes the parity gap. Today this is mostly latent because batch transition structure validation returns UnsupportedFeatureError, but the gap will surface the moment that validator does real work.

source: ['claude-general', 'claude-rust-quality']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/documents/transitions/delete.rs`:
- [SUGGESTION] lines 380-393: Sdk::document_delete still leaks the bumped contract nonce on pre-broadcast failures
  Verified at delete.rs:385-389. The one-shot `Sdk::document_delete` calls `builder.sign(...)` (which internally allocates and bumps the nonce, then delegates to `sign_with_nonce`) and then runs `ensure_valid_state_transition_structure` before broadcasting. If either step fails, the bumped nonce stays in the cache without rollback. The building blocks added in this PR — `DocumentDeleteTransitionBuilder::sign_with_nonce` (delete.rs:209) and `Sdk::rollback_identity_contract_nonce` (sdk.rs:506) — already let the wasm prepare path handle this correctly (document.rs:910-948). Aligning the native one-shot delete with the same allocate → sign-with-nonce → validate → conditional-rollback pattern closes the parity gap. Today this is mostly latent because batch transition structure validation returns `UnsupportedFeatureError`, but the gap will surface the moment that validator does real work.

Comment on lines +215 to +238
#[allow(clippy::too_many_arguments)]
pub async fn build_signed_document_create_transition<S: Signer<IdentityPublicKey>>(
sdk: &Sdk,
document: &Document,
document_type: &DocumentType,
document_state_transition_entropy: Option<[u8; 32]>,
identity_public_key: &IdentityPublicKey,
token_payment_info: Option<TokenPaymentInfo>,
signer: &S,
settings: Option<PutSettings>,
) -> Result<StateTransition, Error> {
ensure_revision_for_create(document.revision())?;
build_signed_document_create_or_replace_transition(
sdk,
document,
document_type,
document_state_transition_entropy,
identity_public_key,
token_payment_info,
signer,
settings,
)
.await
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: build_signed_document_create_transition accepts entropy=None and silently rewrites document.id

Verified at put_document.rs:216-238 and resolve_document_create_entropy at lines 72-91. The new strict-create public wrapper takes document_state_transition_entropy: Option<[u8; 32]> and forwards None through to the shared resolver, which generates fresh entropy via StdRng::from_entropy() and overwrites document.id via Document::generate_document_id_v0. The wasm-sdk caller always passes Some(...), so this branch is unreachable from JS today, but the function is pub and is now the canonical fail-fast create entrypoint for native rs-sdk and rs-sdk-ffi consumers. A native caller that constructs a Document with a chosen id and passes None for entropy will receive a transition that mutated their document to embed a different id, with no signal in the API surface that this can happen. Tightening the parameter to [u8; 32] (so callers cannot accidentally trigger the regeneration) or returning the resolved entropy/id explicitly would make this safe by construction. The replace wrapper at lines 254-275 correctly hard-codes None because entropy is unused there — only the create wrapper needs the change.

source: ['claude-rust-quality', 'codex-rust-quality']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 215-238: build_signed_document_create_transition accepts entropy=None and silently rewrites document.id
  Verified at put_document.rs:216-238 and resolve_document_create_entropy at lines 72-91. The new strict-create public wrapper takes `document_state_transition_entropy: Option<[u8; 32]>` and forwards `None` through to the shared resolver, which generates fresh entropy via `StdRng::from_entropy()` and overwrites `document.id` via `Document::generate_document_id_v0`. The wasm-sdk caller always passes `Some(...)`, so this branch is unreachable from JS today, but the function is `pub` and is now the canonical fail-fast create entrypoint for native rs-sdk and rs-sdk-ffi consumers. A native caller that constructs a `Document` with a chosen `id` and passes `None` for entropy will receive a transition that mutated their document to embed a different id, with no signal in the API surface that this can happen. Tightening the parameter to `[u8; 32]` (so callers cannot accidentally trigger the regeneration) or returning the resolved entropy/id explicitly would make this safe by construction. The replace wrapper at lines 254-275 correctly hard-codes `None` because entropy is unused there — only the create wrapper needs the change.

Comment on lines +154 to +200
pub async fn build_signed_document_create_or_replace_transition<S: Signer<IdentityPublicKey>>(
sdk: &Sdk,
document: &Document,
document_type: &DocumentType,
document_state_transition_entropy: Option<[u8; 32]>,
identity_public_key: &IdentityPublicKey,
token_payment_info: Option<TokenPaymentInfo>,
signer: &S,
settings: Option<PutSettings>,
) -> Result<StateTransition, Error> {
// Reject the always-invalid `Some(0)` revision before allocating any
// nonce. Strict create/replace intent validation is the job of the
// dedicated helpers below.
ensure_revision_nonzero(document.revision())?;

let owner_id = document.owner_id();
let contract_id = document_type.data_contract_id();
let new_identity_contract_nonce = sdk
.get_identity_contract_nonce(owner_id, contract_id, true, settings)
.await?;

let result = build_and_sign_create_or_replace_after_nonce(
sdk,
document,
document_type,
document_state_transition_entropy,
identity_public_key,
token_payment_info,
signer,
settings,
new_identity_contract_nonce,
)
.await;

match result {
Ok(transition) => Ok(transition),
Err(err) => {
sdk.rollback_identity_contract_nonce(
owner_id,
contract_id,
new_identity_contract_nonce,
)
.await;
Err(err)
}
}
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: create-or-replace dispatcher only rejects Some(0); Some(2+) on a 'create' silently produces an unsendable replace

Verified at put_document.rs:167 (ensure_revision_nonzero) and lines 293, 19 (is_document_replace_revision). The public dispatcher remains the path used by PutDocument::put_to_platform (line 342) and only rejects Some(0). Any Some(rev) with rev > INITIAL_REVISION is routed to the replace branch, so a native caller intending to create a brand-new document but accidentally passing Some(2) silently produces a replace transition that can never validate against state — failing only at broadcast, after the contract nonce was bumped (the new rollback narrows the cache impact, but the caller still gets a confusing late-stage error). The new strict wrappers build_signed_document_create_transition / build_signed_document_replace_transition (lines 215-275) already encode the precise intent the wasm-sdk's documentCreate/documentReplace enforce. Routing PutDocument::put_to_platform through the strict create variant when the document revision is unset/INITIAL_REVISION and through the strict replace variant otherwise would give native callers the same fail-fast guard.

source: ['claude-general']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 154-200: create-or-replace dispatcher only rejects Some(0); Some(2+) on a 'create' silently produces an unsendable replace
  Verified at put_document.rs:167 (`ensure_revision_nonzero`) and lines 293, 19 (`is_document_replace_revision`). The public dispatcher remains the path used by `PutDocument::put_to_platform` (line 342) and only rejects `Some(0)`. Any `Some(rev)` with `rev > INITIAL_REVISION` is routed to the replace branch, so a native caller intending to create a brand-new document but accidentally passing `Some(2)` silently produces a replace transition that can never validate against state — failing only at broadcast, after the contract nonce was bumped (the new rollback narrows the cache impact, but the caller still gets a confusing late-stage error). The new strict wrappers `build_signed_document_create_transition` / `build_signed_document_replace_transition` (lines 215-275) already encode the precise intent the wasm-sdk's `documentCreate`/`documentReplace` enforce. Routing `PutDocument::put_to_platform` through the strict create variant when the document revision is unset/`INITIAL_REVISION` and through the strict replace variant otherwise would give native callers the same fail-fast guard.

Comment on lines +188 to +199
match result {
Ok(transition) => Ok(transition),
Err(err) => {
sdk.rollback_identity_contract_nonce(
owner_id,
contract_id,
new_identity_contract_nonce,
)
.await;
Err(err)
}
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Failure-path rollback in build_signed_document_create_or_replace_transition is not directly tested

The central correctness claim of this commit — that any pre-broadcast error after the contract nonce is bumped triggers rollback_identity_contract_nonce — is exercised at the cache layer (in nonce_cache_tests) and end-to-end via the wasm prepare flow, but there is no Rust unit/integration test in put_document.rs that drives build_signed_document_create_or_replace_transition (or the strict create/replace wrappers) through a deliberately-failing build/sign step and asserts the cached nonce was decremented. A future refactor that drops the Err arm at lines 190-198, or accidentally calls refresh_identity_nonce instead of rollback_identity_contract_nonce, would slip past the unit-test layer. Adding a #[cfg(test)] case using a stub signer that returns Err (mirroring the mock pattern already used in nonce_cache_tests) would pin the contract for both helpers.

source: ['claude-rust-quality', 'codex-rust-quality']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 188-199: Failure-path rollback in build_signed_document_create_or_replace_transition is not directly tested
  The central correctness claim of this commit — that any pre-broadcast error after the contract nonce is bumped triggers `rollback_identity_contract_nonce` — is exercised at the cache layer (in `nonce_cache_tests`) and end-to-end via the wasm prepare flow, but there is no Rust unit/integration test in `put_document.rs` that drives `build_signed_document_create_or_replace_transition` (or the strict create/replace wrappers) through a deliberately-failing build/sign step and asserts the cached nonce was decremented. A future refactor that drops the `Err` arm at lines 190-198, or accidentally calls `refresh_identity_nonce` instead of `rollback_identity_contract_nonce`, would slip past the unit-test layer. Adding a `#[cfg(test)]` case using a stub signer that returns `Err` (mirroring the mock pattern already used in `nonce_cache_tests`) would pin the contract for both helpers.

Comment on lines +904 to +949
let builder = if let Some(s) = settings {
builder.with_settings(s)
} else {
builder
};

// Pre-allocate the identity-contract nonce so that any pre-broadcast
// failure (sign or local structure validation) can be rolled back via
// rs-sdk's `rollback_identity_contract_nonce`. The rollback is
// conditional: it only adjusts the cache entry if it still equals the
// nonce allocated here, so it does not clobber concurrent allocations.
let allocated_nonce = self
.inner_sdk()
.get_identity_contract_nonce(owner_id, contract_id, true, settings)
.await?;

let state_transition = match builder
.sign_with_nonce(
allocated_nonce,
&identity_key,
&signer,
self.inner_sdk().version(),
)
.await
{
Ok(st) => st,
Err(err) => {
self.inner_sdk()
.rollback_identity_contract_nonce(owner_id, contract_id, allocated_nonce)
.await;
return Err(err.into());
}
};

// Validate structure before handing the ST back, mirroring rs-sdk's
// pre-broadcast check. For document Batch transitions this currently
// ends up as a no-op because DPP returns UnsupportedFeatureError until
// that structure validation is implemented there.
if let Err(err) =
ensure_valid_state_transition_structure(&state_transition, self.inner_sdk().version())
{
self.inner_sdk()
.rollback_identity_contract_nonce(owner_id, contract_id, allocated_nonce)
.await;
return Err(err.into());
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: prepare_document_delete duplicates the allocate-then-rollback policy inline

Verified at document.rs:910-949. prepare_document_create and prepare_document_replace delegate to rs-sdk's build_signed_document_create_transition / build_signed_document_replace_transition, which centralize the rollback policy. prepare_document_delete reproduces the same dance by hand: get_identity_contract_nonce (lines 915-918), sign_with_nonce (lines 920-927), and two separate match/if-let arms that each call rollback_identity_contract_nonce(owner_id, contract_id, allocated_nonce) (lines 930-935 and 945-948). Hygiene-only — a future change to the rollback policy must touch two sites, and a forget-to-rollback regression on either branch will surface as silent local-cache drift. Exposing a build_signed_document_delete_transition in rs-sdk that mirrors the create/replace helpers would absorb the wrapping in one place and let any future Swift/FFI consumer share the same implementation.

source: ['claude-rust-quality', 'claude-ffi-engineer']

Comment on lines +864 to +873
} else {
(
IdentifierWasm::try_from_options(&document_js, "id")?.into(),
IdentifierWasm::try_from_options(&document_js, "ownerId")?.into(),
IdentifierWasm::try_from_options(&document_js, "dataContractId")?.into(),
try_from_options_with(&document_js, "documentTypeName", |v| {
try_to_string(v, "documentTypeName")
})?,
)
};
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: prepareDocumentDelete plain-object errors lose the document. path prefix

Verified at document.rs:864-873. When the JS caller uses the documented plain-object form ({ document: { id, ownerId, dataContractId, documentTypeName }, ... }), the else branch parses fields directly off the inner document JsValue using IdentifierWasm::try_from_options(&document_js, "id") etc. Resulting errors surface as 'id' is required / 'ownerId' is required against an opaque object, with no indication that the missing field is options.document.id rather than options.id. Pure diagnostics — no correctness impact — but the create/replace paths route through DocumentWasm and don't have this asymmetry. Wrapping these errors with a document. path prefix would keep the JS error surface symmetric with the documented options shape.

💡 Suggested change
Suggested change
} else {
(
IdentifierWasm::try_from_options(&document_js, "id")?.into(),
IdentifierWasm::try_from_options(&document_js, "ownerId")?.into(),
IdentifierWasm::try_from_options(&document_js, "dataContractId")?.into(),
try_from_options_with(&document_js, "documentTypeName", |v| {
try_to_string(v, "documentTypeName")
})?,
)
};
} else {
(
IdentifierWasm::try_from_options(&document_js, "id")
.map_err(|err| WasmSdkError::invalid_argument(format!("document.id: {err}")))?
.into(),
IdentifierWasm::try_from_options(&document_js, "ownerId")
.map_err(|err| WasmSdkError::invalid_argument(format!("document.ownerId: {err}")))?
.into(),
IdentifierWasm::try_from_options(&document_js, "dataContractId")
.map_err(|err| WasmSdkError::invalid_argument(format!("document.dataContractId: {err}")))?
.into(),
try_from_options_with(&document_js, "documentTypeName", |v| {
try_to_string(v, "documentTypeName")
})
.map_err(|err| {
WasmSdkError::invalid_argument(format!("document.documentTypeName: {err}"))
})?,
)
};

source: ['claude-general', 'claude-rust-quality', 'claude-ffi-engineer', 'codex-ffi-engineer']

Comment on lines 486 to +515
pub async fn refresh_identity_nonce(&self, identity_id: &Identifier) {
self.nonce_cache.refresh(identity_id).await;
}

/// Conditionally roll back a previously-bumped identity-contract nonce
/// after a **local** (pre-broadcast) failure.
///
/// Call this only when the caller is certain the nonce was never observed
/// by the network — e.g. when build/sign or local structure validation
/// fails right after a successful
/// [`get_identity_contract_nonce`](Self::get_identity_contract_nonce) with
/// `bump_first = true`. The rollback is conditional: it only adjusts the
/// cache entry if its current nonce still equals `allocated_nonce`,
/// avoiding clobbering concurrent newer allocations. A missing or
/// already-advanced entry is left untouched.
///
/// For broadcast failures keep using
/// [`refresh_identity_nonce`](Self::refresh_identity_nonce), which
/// preserves the cached (bumped) value so the cache cannot regress past
/// a nonce the network may have accepted.
pub async fn rollback_identity_contract_nonce(
&self,
identity_id: Identifier,
contract_id: Identifier,
allocated_nonce: IdentityNonce,
) {
self.nonce_cache
.rollback_identity_contract_nonce(identity_id, contract_id, allocated_nonce)
.await;
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: rollback_identity_contract_nonce takes Identifier by value while refresh_identity_nonce takes &Identifier

Verified at sdk.rs:486 and 506-511. The two halves of the same recovery policy are documented together but take their Identifier arguments asymmetrically (by &Identifier vs by Identifier). Identifier is Copy, so this is not a soundness or perf concern, but it forces callers in both Rust and rs-sdk-ffi to remember which API is which when wrapping or refactoring these. Aligning the signatures (either reference for both, or value for both) removes a small recurring footgun. Low priority; mention only.

source: ['claude-rust-quality']

Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Verified at 70c884b. The PR resolves the prior blocking nonce-leak / silent-id-rewrite issues. One new blocking regression remains: tightening Document::put_to_platform to reject None entropy on the create branch breaks an in-tree native caller (rs-platform-wallet profile creation). Remaining items are non-blocking parity, hygiene, and diagnostics findings on the FFI delete path, the still-permissive public dispatcher, and the JS->wasm boundary.

Reviewed commit: 70c884b

🔴 1 blocking | 🟡 4 suggestion(s) | 💬 4 nitpick(s)

1 additional finding

💬 nitpick: tokenPaymentInfo crosses the JS->wasm boundary via unchecked_into without an is_object check

packages/wasm-sdk/src/state_transitions/document.rs (lines 125-146)

Verified at lines 125-146. try_from_options_optional_token_payment_info rejects only null and undefined, then unchecked_into::<TokenPaymentInfoOptionsJs>() force-casts whatever JS value was supplied before handing it to TokenPaymentInfoWasm::constructor. Because the constructor re-reads via Reflect, callers passing tokenPaymentInfo: 42 or tokenPaymentInfo: "x" get cascading per-field '<field>' is required diagnostics rather than a clear tokenPaymentInfo must be an object. No memory-safety risk — wasm-bindgen's unchecked_into is safe on the Rust side — but the boundary contract is implicit. The new prepare* APIs widen the surface that flows through this helper, making a clean error worth the small additional check.

💡 Suggested change
    if token_payment_info_value.is_null() || token_payment_info_value.is_undefined() {
        return Ok(None);
    }

    if !token_payment_info_value.is_object() {
        return Err(WasmSdkError::invalid_argument(
            "tokenPaymentInfo must be an object",
        ));
    }

    let token_payment_info = TokenPaymentInfoWasm::constructor(
        token_payment_info_value.unchecked_into::<TokenPaymentInfoOptionsJs>(),
    )
    .map_err(|err| WasmSdkError::invalid_argument(err.to_string()))?;

    Ok(Some(token_payment_info.into()))
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [BLOCKING] lines 355-393: PutDocument::put_to_platform create path now rejects an in-tree native caller (rs-platform-wallet profile creation)
  Verified at put_document.rs:355-393. Pre-this-commit, the create branch forwarded `document_state_transition_entropy` to `build_signed_document_create_or_replace_transition`, which silently regenerated entropy and rewrote `document.id` when `None` was supplied. After this commit, the dispatcher routes `revision.is_none() || revision == Some(INITIAL_REVISION)` through the strict create wrapper and hard-errors with `Error::Generic("InvalidArgument: document_state_transition_entropy is required ...")` when entropy is `None`.

This is the right safety improvement, but the PR scopes the new revision/entropy guards to the wasm-sdk prepare paths and does not flag the rs-sdk trait-behavior change. There is at least one in-repo native caller that relies on the old auto-entropy behavior: `packages/rs-platform-wallet/src/wallet/identity/network/profile.rs:353-364` calls `stub_document.put_to_platform_and_wait_for_response(.., document_type, None /* entropy */, signing_key, None, &SignerRef(signer), None)` on a freshly-built `DocumentV0 { revision: None, id: Identifier::from([0u8; 32]), .. }`. Under HEAD, this returns `InvalidArgument` before broadcast and DashPay profile creation regresses at runtime.

Either (a) restore the auto-id-generation fallback for the trait path (and keep the strict requirement only in the new strict-create wrapper / wasm prepare APIs), (b) update `rs-platform-wallet`'s profile create path to derive entropy + document id locally and pass `Some(entropy)` so the strict contract is satisfied, or (c) introduce the strict requirement as a new API and leave the existing trait method backward-compatible. Whichever route is chosen, this also needs a release note: any third-party native rs-sdk consumer of `Document::put_to_platform{,_and_wait_for_response}` that omitted entropy will hit the same regression on upgrade.
- [SUGGESTION] lines 232-243: Strict create helper does not locally enforce document.id matches the supplied entropy
  Verified at put_document.rs:222-244. `build_signed_document_create_transition` documents that the supplied entropy "must match the entropy used to derive the document's `id`", but the implementation only enforces the revision boundary (`ensure_revision_for_create`) and delegates to the generic builder. `build_and_sign_create_or_replace_after_nonce` then forwards both `document.id()` and the entropy into DPP without checking they agree.

When they disagree, the invariant is only caught later in Drive-ABCI advanced-structure validation, after this prepare/one-shot API has already allocated a nonce and signed. Because this PR is explicitly introducing pre-broadcast builders for idempotent retries, this remaining late failure undermines the contract: callers should get a local `InvalidArgument` before any nonce allocation or signing when `document.id != Document::generate_document_id_v0(contract_id, owner_id, document_type_name, entropy)`. Add the check at the top of `build_signed_document_create_transition` (right after `ensure_revision_for_create`).
- [SUGGESTION] lines 72-199: Public dispatcher still silently regenerates entropy and rewrites document.id when entropy=None on the create branch
  Verified at put_document.rs:72-91 and 154-200. The strict `build_signed_document_create_transition` (lines 222-244) now takes `[u8; 32]` and `PutDocument::put_to_platform` rejects `None` entropy, closing the ambient hazard for the canonical paths. But `build_signed_document_create_or_replace_transition` is still `pub`, still accepts `Option<[u8; 32]>`, and on the create branch still feeds `None` into `resolve_document_create_entropy`, which generates fresh entropy via `StdRng::from_entropy()` and rewrites `document.id` via `Document::generate_document_id_v0`. No in-tree caller currently trips this branch (wasm-sdk and the new PutDocument dispatcher both supply `Some(...)` or hard-error), but a future native rs-sdk or rs-sdk-ffi consumer reaching for the dispatcher directly with their own pre-derived id will silently receive a transition that mutated their document. Either tighten the dispatcher's create-branch entropy parameter to `[u8; 32]` (split into create vs replace dispatchers, or require entropy on a strict create-only signature), or surface the regenerated entropy/id in the return so the rewrite cannot be silent. The replace branch is unaffected.

In `packages/rs-sdk-ffi/src/document/delete.rs`:
- [SUGGESTION] lines 174-218: FFI delete prepare path skips ensure_valid_state_transition_structure that rs-sdk and wasm-sdk both run
  Verified at delete.rs:174-218. `dash_sdk_document_delete` allocates the contract nonce, calls `sign_with_nonce`, and goes straight to `bincode::encode_to_vec` without invoking `ensure_valid_state_transition_structure(&state_transition, sdk.version())`. Both peer paths now run the validator between sign and the next step:
- rs-sdk `Sdk::document_delete` (packages/rs-sdk/src/platform/documents/transitions/delete.rs:427-433), with rollback on failure.
- wasm-sdk `prepareDocumentDelete` (packages/wasm-sdk/src/state_transitions/document.rs:942-949), with rollback on failure.

Today this is masked because batch transition structure validation in DPP returns `UnsupportedFeatureError` and is filtered out, but the moment that validator does real work, Swift/C consumers will silently emit invalid signed bytes for caching/rebroadcast while wasm and native Rust consumers reject them locally. Add the same call here (with `rollback_identity_contract_nonce` on `Err`) to close the parity gap and match the function header's documented pre-broadcast semantics.

In `packages/rs-sdk/src/platform/documents/transitions/delete.rs`:
- [SUGGESTION] lines 387-433: Sdk::document_delete's new allocate-then-rollback contract has no direct Rust test
  This commit adds the same allocate->sign_with_nonce->validate->rollback-on-error pattern to `Sdk::document_delete` (lines 402-433) that was added to the strict create helper. The strict create helper got a new `#[cfg(test)]` test `build_signed_document_create_rolls_back_nonce_on_signing_failure` (put_document.rs:623-690) using `AlwaysFailingSigner` + `expect_fetch::<IdentityContractNonceFetcher, _>` — that pattern is directly applicable here. Failure-path coverage for `Sdk::document_delete`'s new rollback lives only in TS via the wasm-sdk prepare suite, which exercises a separate code shape. A future refactor that drops one of the rollback arms (sign-failure at lines 416-424 or validation-failure at lines 427-433), or that swaps `rollback_identity_contract_nonce` for `refresh_identity_nonce`, would slip past the unit-test layer. Mirror the create-side test against `Sdk::document_delete` to pin both branches.

Comment on lines +355 to 393
let transition = if self.revision().is_none() || self.revision() == Some(INITIAL_REVISION) {
// Create path: entropy is required. Reject `None` *before* we
// allocate a nonce so we never advance the cache for an
// attempt we know cannot succeed. We deliberately do NOT
// silently fall back to regenerating the document id — the
// caller's id must match the entropy they supplied.
let entropy = document_state_transition_entropy.ok_or_else(|| {
Error::Generic(
"InvalidArgument: document_state_transition_entropy is required \
when calling put_to_platform on the create path; supply the \
32-byte entropy that was used to derive the document id"
.to_string(),
)
})?;
build_signed_document_create_transition(
sdk,
self,
&document_type,
entropy,
&identity_public_key,
new_identity_contract_nonce,
settings.user_fee_increase.unwrap_or_default(),
token_payment_info,
signer,
sdk.version(),
settings.state_transition_creation_options,
settings,
)
.await?
} else {
let (document, document_state_transition_entropy) = document_state_transition_entropy
.map(|entropy| (self.clone(), entropy))
.unwrap_or_else(|| {
let mut rng = StdRng::from_entropy();
let mut document = self.clone();
let entropy = rng.gen::<[u8; 32]>();
document.set_id(Document::generate_document_id_v0(
&document_type.data_contract_id(),
&document.owner_id(),
document_type.name(),
entropy.as_slice(),
));
(document, entropy)
});
BatchTransition::new_document_creation_transition_from_document(
document,
document_type.as_ref(),
document_state_transition_entropy,
// Replace path: entropy is unused; the strict helper enforces
// `revision > INITIAL_REVISION`.
build_signed_document_replace_transition(
sdk,
self,
&document_type,
&identity_public_key,
new_identity_contract_nonce,
settings.user_fee_increase.unwrap_or_default(),
token_payment_info,
signer,
sdk.version(),
settings.state_transition_creation_options,
settings,
)
.await?
};
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: PutDocument::put_to_platform create path now rejects an in-tree native caller (rs-platform-wallet profile creation)

Verified at put_document.rs:355-393. Pre-this-commit, the create branch forwarded document_state_transition_entropy to build_signed_document_create_or_replace_transition, which silently regenerated entropy and rewrote document.id when None was supplied. After this commit, the dispatcher routes revision.is_none() || revision == Some(INITIAL_REVISION) through the strict create wrapper and hard-errors with Error::Generic("InvalidArgument: document_state_transition_entropy is required ...") when entropy is None.

This is the right safety improvement, but the PR scopes the new revision/entropy guards to the wasm-sdk prepare paths and does not flag the rs-sdk trait-behavior change. There is at least one in-repo native caller that relies on the old auto-entropy behavior: packages/rs-platform-wallet/src/wallet/identity/network/profile.rs:353-364 calls stub_document.put_to_platform_and_wait_for_response(.., document_type, None /* entropy */, signing_key, None, &SignerRef(signer), None) on a freshly-built DocumentV0 { revision: None, id: Identifier::from([0u8; 32]), .. }. Under HEAD, this returns InvalidArgument before broadcast and DashPay profile creation regresses at runtime.

Either (a) restore the auto-id-generation fallback for the trait path (and keep the strict requirement only in the new strict-create wrapper / wasm prepare APIs), (b) update rs-platform-wallet's profile create path to derive entropy + document id locally and pass Some(entropy) so the strict contract is satisfied, or (c) introduce the strict requirement as a new API and leave the existing trait method backward-compatible. Whichever route is chosen, this also needs a release note: any third-party native rs-sdk consumer of Document::put_to_platform{,_and_wait_for_response} that omitted entropy will hit the same regression on upgrade.

source: ['codex', 'claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [BLOCKING] lines 355-393: PutDocument::put_to_platform create path now rejects an in-tree native caller (rs-platform-wallet profile creation)
  Verified at put_document.rs:355-393. Pre-this-commit, the create branch forwarded `document_state_transition_entropy` to `build_signed_document_create_or_replace_transition`, which silently regenerated entropy and rewrote `document.id` when `None` was supplied. After this commit, the dispatcher routes `revision.is_none() || revision == Some(INITIAL_REVISION)` through the strict create wrapper and hard-errors with `Error::Generic("InvalidArgument: document_state_transition_entropy is required ...")` when entropy is `None`.

This is the right safety improvement, but the PR scopes the new revision/entropy guards to the wasm-sdk prepare paths and does not flag the rs-sdk trait-behavior change. There is at least one in-repo native caller that relies on the old auto-entropy behavior: `packages/rs-platform-wallet/src/wallet/identity/network/profile.rs:353-364` calls `stub_document.put_to_platform_and_wait_for_response(.., document_type, None /* entropy */, signing_key, None, &SignerRef(signer), None)` on a freshly-built `DocumentV0 { revision: None, id: Identifier::from([0u8; 32]), .. }`. Under HEAD, this returns `InvalidArgument` before broadcast and DashPay profile creation regresses at runtime.

Either (a) restore the auto-id-generation fallback for the trait path (and keep the strict requirement only in the new strict-create wrapper / wasm prepare APIs), (b) update `rs-platform-wallet`'s profile create path to derive entropy + document id locally and pass `Some(entropy)` so the strict contract is satisfied, or (c) introduce the strict requirement as a new API and leave the existing trait method backward-compatible. Whichever route is chosen, this also needs a release note: any third-party native rs-sdk consumer of `Document::put_to_platform{,_and_wait_for_response}` that omitted entropy will hit the same regression on upgrade.

Comment on lines +174 to +218
let state_transition = match builder
.sign_with_nonce(
identity_contract_nonce,
identity_public_key,
signer,
wrapper.sdk.version(),
)
.await
.map_err(|e| {
FFIError::InternalError(format!("Failed to create delete transition: {}", e))
})?;
{
Ok(transition) => transition,
Err(e) => {
wrapper
.sdk
.rollback_identity_contract_nonce(
owner_identifier,
contract_id_for_nonce,
identity_contract_nonce,
)
.await;
return Err(FFIError::InternalError(format!(
"Failed to create delete transition: {}",
e
)));
}
};

// Serialize the state transition with bincode
let config = bincode::config::standard();
let serialized = bincode::encode_to_vec(&state_transition, config).map_err(|e| {
FFIError::InternalError(format!("Failed to serialize state transition: {}", e))
})?;
let serialized = match bincode::encode_to_vec(&state_transition, config) {
Ok(bytes) => bytes,
Err(e) => {
wrapper
.sdk
.rollback_identity_contract_nonce(
owner_identifier,
contract_id_for_nonce,
identity_contract_nonce,
)
.await;
return Err(FFIError::InternalError(format!(
"Failed to serialize state transition: {}",
e
)));
}
};
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: FFI delete prepare path skips ensure_valid_state_transition_structure that rs-sdk and wasm-sdk both run

Verified at delete.rs:174-218. dash_sdk_document_delete allocates the contract nonce, calls sign_with_nonce, and goes straight to bincode::encode_to_vec without invoking ensure_valid_state_transition_structure(&state_transition, sdk.version()). Both peer paths now run the validator between sign and the next step:

  • rs-sdk Sdk::document_delete (packages/rs-sdk/src/platform/documents/transitions/delete.rs:427-433), with rollback on failure.
  • wasm-sdk prepareDocumentDelete (packages/wasm-sdk/src/state_transitions/document.rs:942-949), with rollback on failure.

Today this is masked because batch transition structure validation in DPP returns UnsupportedFeatureError and is filtered out, but the moment that validator does real work, Swift/C consumers will silently emit invalid signed bytes for caching/rebroadcast while wasm and native Rust consumers reject them locally. Add the same call here (with rollback_identity_contract_nonce on Err) to close the parity gap and match the function header's documented pre-broadcast semantics.

source: ['claude', 'codex']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk-ffi/src/document/delete.rs`:
- [SUGGESTION] lines 174-218: FFI delete prepare path skips ensure_valid_state_transition_structure that rs-sdk and wasm-sdk both run
  Verified at delete.rs:174-218. `dash_sdk_document_delete` allocates the contract nonce, calls `sign_with_nonce`, and goes straight to `bincode::encode_to_vec` without invoking `ensure_valid_state_transition_structure(&state_transition, sdk.version())`. Both peer paths now run the validator between sign and the next step:
- rs-sdk `Sdk::document_delete` (packages/rs-sdk/src/platform/documents/transitions/delete.rs:427-433), with rollback on failure.
- wasm-sdk `prepareDocumentDelete` (packages/wasm-sdk/src/state_transitions/document.rs:942-949), with rollback on failure.

Today this is masked because batch transition structure validation in DPP returns `UnsupportedFeatureError` and is filtered out, but the moment that validator does real work, Swift/C consumers will silently emit invalid signed bytes for caching/rebroadcast while wasm and native Rust consumers reject them locally. Add the same call here (with `rollback_identity_contract_nonce` on `Err`) to close the parity gap and match the function header's documented pre-broadcast semantics.

Comment on lines +232 to +243
ensure_revision_for_create(document.revision())?;
build_signed_document_create_or_replace_transition(
sdk,
document,
document_type,
Some(document_state_transition_entropy),
identity_public_key,
token_payment_info,
signer,
settings,
)
.await
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Strict create helper does not locally enforce document.id matches the supplied entropy

Verified at put_document.rs:222-244. build_signed_document_create_transition documents that the supplied entropy "must match the entropy used to derive the document's id", but the implementation only enforces the revision boundary (ensure_revision_for_create) and delegates to the generic builder. build_and_sign_create_or_replace_after_nonce then forwards both document.id() and the entropy into DPP without checking they agree.

When they disagree, the invariant is only caught later in Drive-ABCI advanced-structure validation, after this prepare/one-shot API has already allocated a nonce and signed. Because this PR is explicitly introducing pre-broadcast builders for idempotent retries, this remaining late failure undermines the contract: callers should get a local InvalidArgument before any nonce allocation or signing when document.id != Document::generate_document_id_v0(contract_id, owner_id, document_type_name, entropy). Add the check at the top of build_signed_document_create_transition (right after ensure_revision_for_create).

source: ['codex']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 232-243: Strict create helper does not locally enforce document.id matches the supplied entropy
  Verified at put_document.rs:222-244. `build_signed_document_create_transition` documents that the supplied entropy "must match the entropy used to derive the document's `id`", but the implementation only enforces the revision boundary (`ensure_revision_for_create`) and delegates to the generic builder. `build_and_sign_create_or_replace_after_nonce` then forwards both `document.id()` and the entropy into DPP without checking they agree.

When they disagree, the invariant is only caught later in Drive-ABCI advanced-structure validation, after this prepare/one-shot API has already allocated a nonce and signed. Because this PR is explicitly introducing pre-broadcast builders for idempotent retries, this remaining late failure undermines the contract: callers should get a local `InvalidArgument` before any nonce allocation or signing when `document.id != Document::generate_document_id_v0(contract_id, owner_id, document_type_name, entropy)`. Add the check at the top of `build_signed_document_create_transition` (right after `ensure_revision_for_create`).

Comment on lines 72 to +199
@@ -46,6 +121,218 @@ pub trait PutDocument<S: Signer<IdentityPublicKey>>: Waitable {
) -> Result<Document, Error>;
}

/// Build, sign, and structurally validate a document create-or-replace
/// [`StateTransition`] without broadcasting it.
///
/// This is the pre-broadcast half of [`PutDocument::put_to_platform`]: it
/// allocates a fresh identity-contract nonce, picks the create-vs-replace
/// branch based on the document's revision, fills in entropy when missing,
/// applies `user_fee_increase` / `token_payment_info` /
/// `state_transition_creation_options` from `settings`, signs the transition,
/// and runs structure validation. The caller decides whether (and how) to
/// broadcast the returned, signed transition.
///
/// # Revision validation
///
/// The dispatch is driven by the document revision and rejects only the
/// always-invalid `Some(0)` case. Use
/// [`build_signed_document_create_transition`] or
/// [`build_signed_document_replace_transition`] for fail-fast validation that
/// also enforces caller intent (mismatched create/replace revisions error
/// before any nonce allocation).
///
/// # Nonce handling on local errors
///
/// On any **pre-broadcast** failure (build, sign, or local structure
/// validation) this helper conditionally rolls back the bumped
/// identity-contract nonce via
/// [`Sdk::rollback_identity_contract_nonce`], so the local cache does not
/// advance past a nonce the network never observed. The rollback only adjusts
/// the cache entry if it still equals the nonce allocated by this attempt, so
/// concurrent allocations are not clobbered.
#[allow(clippy::too_many_arguments)]
pub async fn build_signed_document_create_or_replace_transition<S: Signer<IdentityPublicKey>>(
sdk: &Sdk,
document: &Document,
document_type: &DocumentType,
document_state_transition_entropy: Option<[u8; 32]>,
identity_public_key: &IdentityPublicKey,
token_payment_info: Option<TokenPaymentInfo>,
signer: &S,
settings: Option<PutSettings>,
) -> Result<StateTransition, Error> {
// Reject the always-invalid `Some(0)` revision before allocating any
// nonce. Strict create/replace intent validation is the job of the
// dedicated helpers below.
ensure_revision_nonzero(document.revision())?;

let owner_id = document.owner_id();
let contract_id = document_type.data_contract_id();
let new_identity_contract_nonce = sdk
.get_identity_contract_nonce(owner_id, contract_id, true, settings)
.await?;

let result = build_and_sign_create_or_replace_after_nonce(
sdk,
document,
document_type,
document_state_transition_entropy,
identity_public_key,
token_payment_info,
signer,
settings,
new_identity_contract_nonce,
)
.await;

match result {
Ok(transition) => Ok(transition),
Err(err) => {
sdk.rollback_identity_contract_nonce(
owner_id,
contract_id,
new_identity_contract_nonce,
)
.await;
Err(err)
}
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Public dispatcher still silently regenerates entropy and rewrites document.id when entropy=None on the create branch

Verified at put_document.rs:72-91 and 154-200. The strict build_signed_document_create_transition (lines 222-244) now takes [u8; 32] and PutDocument::put_to_platform rejects None entropy, closing the ambient hazard for the canonical paths. But build_signed_document_create_or_replace_transition is still pub, still accepts Option<[u8; 32]>, and on the create branch still feeds None into resolve_document_create_entropy, which generates fresh entropy via StdRng::from_entropy() and rewrites document.id via Document::generate_document_id_v0. No in-tree caller currently trips this branch (wasm-sdk and the new PutDocument dispatcher both supply Some(...) or hard-error), but a future native rs-sdk or rs-sdk-ffi consumer reaching for the dispatcher directly with their own pre-derived id will silently receive a transition that mutated their document. Either tighten the dispatcher's create-branch entropy parameter to [u8; 32] (split into create vs replace dispatchers, or require entropy on a strict create-only signature), or surface the regenerated entropy/id in the return so the rewrite cannot be silent. The replace branch is unaffected.

source: ['claude', 'codex']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 72-199: Public dispatcher still silently regenerates entropy and rewrites document.id when entropy=None on the create branch
  Verified at put_document.rs:72-91 and 154-200. The strict `build_signed_document_create_transition` (lines 222-244) now takes `[u8; 32]` and `PutDocument::put_to_platform` rejects `None` entropy, closing the ambient hazard for the canonical paths. But `build_signed_document_create_or_replace_transition` is still `pub`, still accepts `Option<[u8; 32]>`, and on the create branch still feeds `None` into `resolve_document_create_entropy`, which generates fresh entropy via `StdRng::from_entropy()` and rewrites `document.id` via `Document::generate_document_id_v0`. No in-tree caller currently trips this branch (wasm-sdk and the new PutDocument dispatcher both supply `Some(...)` or hard-error), but a future native rs-sdk or rs-sdk-ffi consumer reaching for the dispatcher directly with their own pre-derived id will silently receive a transition that mutated their document. Either tighten the dispatcher's create-branch entropy parameter to `[u8; 32]` (split into create vs replace dispatchers, or require entropy on a strict create-only signature), or surface the regenerated entropy/id in the return so the rewrite cannot be silent. The replace branch is unaffected.

Comment on lines +393 to +433
let platform_version = self.version();

let put_settings = delete_document_transition_builder.settings;
let owner_id = delete_document_transition_builder.owner_id;
let contract_id = delete_document_transition_builder.data_contract.id();

let state_transition = delete_document_transition_builder
.sign(self, signing_key, signer, platform_version)
// Allocate the identity-contract nonce explicitly so we can roll it
// back on pre-broadcast failures without leaving the local cache
// advanced past a nonce the network never observed.
let identity_contract_nonce = self
.get_identity_contract_nonce(owner_id, contract_id, true, put_settings)
.await?;

let state_transition = match delete_document_transition_builder
.sign_with_nonce(
identity_contract_nonce,
signing_key,
signer,
platform_version,
)
.await
{
Ok(transition) => transition,
Err(err) => {
self.rollback_identity_contract_nonce(
owner_id,
contract_id,
identity_contract_nonce,
)
.await;
return Err(err);
}
};

if let Err(err) =
ensure_valid_state_transition_structure(&state_transition, platform_version)
{
self.rollback_identity_contract_nonce(owner_id, contract_id, identity_contract_nonce)
.await;
return Err(err);
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Sdk::document_delete's new allocate-then-rollback contract has no direct Rust test

This commit adds the same allocate->sign_with_nonce->validate->rollback-on-error pattern to Sdk::document_delete (lines 402-433) that was added to the strict create helper. The strict create helper got a new #[cfg(test)] test build_signed_document_create_rolls_back_nonce_on_signing_failure (put_document.rs:623-690) using AlwaysFailingSigner + expect_fetch::<IdentityContractNonceFetcher, _> — that pattern is directly applicable here. Failure-path coverage for Sdk::document_delete's new rollback lives only in TS via the wasm-sdk prepare suite, which exercises a separate code shape. A future refactor that drops one of the rollback arms (sign-failure at lines 416-424 or validation-failure at lines 427-433), or that swaps rollback_identity_contract_nonce for refresh_identity_nonce, would slip past the unit-test layer. Mirror the create-side test against Sdk::document_delete to pin both branches.

source: ['claude', 'codex']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/documents/transitions/delete.rs`:
- [SUGGESTION] lines 387-433: Sdk::document_delete's new allocate-then-rollback contract has no direct Rust test
  This commit adds the same allocate->sign_with_nonce->validate->rollback-on-error pattern to `Sdk::document_delete` (lines 402-433) that was added to the strict create helper. The strict create helper got a new `#[cfg(test)]` test `build_signed_document_create_rolls_back_nonce_on_signing_failure` (put_document.rs:623-690) using `AlwaysFailingSigner` + `expect_fetch::<IdentityContractNonceFetcher, _>` — that pattern is directly applicable here. Failure-path coverage for `Sdk::document_delete`'s new rollback lives only in TS via the wasm-sdk prepare suite, which exercises a separate code shape. A future refactor that drops one of the rollback arms (sign-failure at lines 416-424 or validation-failure at lines 427-433), or that swaps `rollback_identity_contract_nonce` for `refresh_identity_nonce`, would slip past the unit-test layer. Mirror the create-side test against `Sdk::document_delete` to pin both branches.

Comment on lines +910 to +949
// Pre-allocate the identity-contract nonce so that any pre-broadcast
// failure (sign or local structure validation) can be rolled back via
// rs-sdk's `rollback_identity_contract_nonce`. The rollback is
// conditional: it only adjusts the cache entry if it still equals the
// nonce allocated here, so it does not clobber concurrent allocations.
let allocated_nonce = self
.inner_sdk()
.get_identity_contract_nonce(owner_id, contract_id, true, settings)
.await?;

let state_transition = match builder
.sign_with_nonce(
allocated_nonce,
&identity_key,
&signer,
self.inner_sdk().version(),
)
.await
{
Ok(st) => st,
Err(err) => {
self.inner_sdk()
.rollback_identity_contract_nonce(owner_id, contract_id, allocated_nonce)
.await;
return Err(err.into());
}
};

// Validate structure before handing the ST back, mirroring rs-sdk's
// pre-broadcast check. For document Batch transitions this currently
// ends up as a no-op because DPP returns UnsupportedFeatureError until
// that structure validation is implemented there.
if let Err(err) =
ensure_valid_state_transition_structure(&state_transition, self.inner_sdk().version())
{
self.inner_sdk()
.rollback_identity_contract_nonce(owner_id, contract_id, allocated_nonce)
.await;
return Err(err.into());
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Allocate-then-rollback policy for document delete is now duplicated across three crates

The same allocate->sign_with_nonce->(optional validate)->conditional-rollback dance now lives in three places: rs-sdk Sdk::document_delete (packages/rs-sdk/src/platform/documents/transitions/delete.rs:402-433), rs-sdk-ffi dash_sdk_document_delete (packages/rs-sdk-ffi/src/document/delete.rs:157-218), and wasm-sdk prepare_document_delete (this file, lines 910-949). The three differ in small ways (the FFI variant skips ensure_valid_state_transition_structure and adds a rollback for bincode encoding; the others run the validator with rollback). Hygiene-only — a future change to the recovery policy must touch three differently-shaped sites, and a forget-to-rollback regression on any branch will surface only as silent local-cache drift in that consumer. Exposing a build_signed_document_delete_transition in rs-sdk that mirrors the create/replace helpers would absorb the orchestration in one place.

source: ['claude', 'codex']

Comment on lines +864 to +873
} else {
(
IdentifierWasm::try_from_options(&document_js, "id")?.into(),
IdentifierWasm::try_from_options(&document_js, "ownerId")?.into(),
IdentifierWasm::try_from_options(&document_js, "dataContractId")?.into(),
try_from_options_with(&document_js, "documentTypeName", |v| {
try_to_string(v, "documentTypeName")
})?,
)
};
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: prepareDocumentDelete plain-object errors lose the document. path prefix

Verified at document.rs:864-873. When the JS caller uses the documented plain-object form ({ document: { id, ownerId, dataContractId, documentTypeName }, ... }), the else branch parses fields directly off the inner document JsValue using IdentifierWasm::try_from_options(&document_js, "id") etc. Resulting failures surface as 'id' is required / 'ownerId' is required against an opaque object, with no indication that the missing field is options.document.id rather than options.id. Pure diagnostics — no correctness impact — but the create/replace paths route through DocumentWasm and don't have this asymmetry. Wrapping these errors with a document. path prefix keeps the JS error surface symmetric with the documented options shape. The same diagnostic gap exists on documentDelete at lines 498-506.

💡 Suggested change
Suggested change
} else {
(
IdentifierWasm::try_from_options(&document_js, "id")?.into(),
IdentifierWasm::try_from_options(&document_js, "ownerId")?.into(),
IdentifierWasm::try_from_options(&document_js, "dataContractId")?.into(),
try_from_options_with(&document_js, "documentTypeName", |v| {
try_to_string(v, "documentTypeName")
})?,
)
};
} else {
(
IdentifierWasm::try_from_options(&document_js, "id")
.map_err(|err| WasmSdkError::invalid_argument(format!("document.id: {err}")))?
.into(),
IdentifierWasm::try_from_options(&document_js, "ownerId")
.map_err(|err| WasmSdkError::invalid_argument(format!("document.ownerId: {err}")))?
.into(),
IdentifierWasm::try_from_options(&document_js, "dataContractId")
.map_err(|err| WasmSdkError::invalid_argument(format!("document.dataContractId: {err}")))?
.into(),
try_from_options_with(&document_js, "documentTypeName", |v| {
try_to_string(v, "documentTypeName")
})
.map_err(|err| {
WasmSdkError::invalid_argument(format!("document.documentTypeName: {err}"))
})?,
)
};

source: ['claude', 'codex']

Comment on lines +904 to +908
let builder = if let Some(s) = settings {
builder.with_settings(s)
} else {
builder
};
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: with_settings on the prepare-delete builder is dead state once the manual sign_with_nonce route is taken

Lines 904-908 stash settings on the builder via builder.with_settings(s), but the prepare path then calls sign_with_nonce (920-927) which in DocumentDeleteTransitionBuilder::sign_with_nonce (rs-sdk documents/transitions/delete.rs:209-253) only consults self.user_fee_increase, self.token_payment_info, and self.state_transition_creation_options — not self.settings. The settings are also passed independently into get_identity_contract_nonce at line 917 (the only place the builder's sign() method would have used them). No correctness impact today (the same value is consumed at line 917), but it's a foot-gun: a maintainer who later switches the prepare path to builder.sign(...) will silently double-apply the setting, and any audit assuming builder fields are load-bearing will misread the call site. Either drop the with_settings call here or extend sign_with_nonce to consume it for parity with sign().

source: ['claude']

Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Verified at f4416d8: no blocking issues. The PR cleanly introduces two-phase document prepare APIs in wasm-sdk plus a strict pre-broadcast pipeline + conditional identity-contract nonce rollback in rs-sdk. Findings center on a public→private API break for build_signed_document_create_or_replace_transition, an order-dependent with_settings overwrite on the delete builder, and several architectural/polish nits (duplicated prepare+rollback recipe across rs-sdk/wasm-sdk/rs-sdk-ffi, an unreachable allocated_nonce == 0 guard, a redundant inner revision check, and a user-facing revision=0 error that bounces callers between APIs). 3 lower-value findings dropped.

Reviewed commit: f4416d8

🟡 5 suggestion(s) | 💬 5 nitpick(s)

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 223-224: `build_signed_document_create_or_replace_transition` was demoted from `pub` to private — source-breaking for native callers
  Git history confirms this commit changed `pub async fn build_signed_document_create_or_replace_transition` to a private `async fn`. The enclosing `platform::transition::put_document` module is still `pub`, so this is a source-breaking change for downstream Rust consumers that were using the helper to prepare a signed document transition with revision-driven create/replace dispatch (and the legacy `entropy = None` fallback) without broadcasting. The new public replacements do not cover that exact surface: `build_signed_document_create_transition` requires the caller to commit to the create intent up front and supply matching entropy, `build_signed_document_replace_transition` is replace-only, and `PutDocument::put_to_platform` broadcasts immediately. If the visibility reduction is intentional, mention it in the PR notes and provide a migration path; otherwise restore `pub` and keep the strict helpers as the recommended entry points.
- [SUGGESTION] lines 434-495: Legacy `PutDocument::put_to_platform` now rejects `Some(entropy)` whose derived id ≠ `document.id` locally — undocumented compat change
  Before this PR, `PutDocument::put_to_platform` with `document_state_transition_entropy = Some(e)` passed the entropy and the caller's `document.id` through to `BatchTransition::new_document_creation_transition_from_document` unchecked; mismatches were rejected server-side. After the refactor, `put_to_platform` routes the create path through `build_signed_document_create_transition` (line 464), whose `ensure_document_id_matches_entropy` guard rejects mismatches locally with `Error::Generic("InvalidArgument: document.id does not match generate_document_id_v0(...)")`. In-tree callers (`rs-platform-wallet` profile creation, etc.) all pass `None`, which still flows through `resolve_document_create_entropy` and is unaffected. But external rs-sdk consumers that built `document.id` themselves and supplied entropy explicitly will now hit a hard local error where the old code failed remotely. The new behavior is strictly more correct; it just isn't called out alongside the documented revision tightening. Add a line to the compat note in the module docstring (and the PR description) covering the entropy/id tightening for `Some(entropy)` callers.

In `packages/rs-sdk/src/platform/documents/transitions/delete.rs`:
- [SUGGESTION] lines 127-137: `with_settings` silently overwrites explicit `with_user_fee_increase` / `with_state_transition_creation_options` values
  `with_settings` now unconditionally copies `settings.user_fee_increase` and `settings.state_transition_creation_options` into the dedicated builder fields whenever they are `Some`. This makes the final value order-dependent: `builder.with_user_fee_increase(42).with_settings(s_with_7)` ends at 7, while `builder.with_settings(s_with_7).with_user_fee_increase(42)` ends at 42. The FFI path at `packages/rs-sdk-ffi/src/document/delete.rs:146-152` actually depends on the second ordering. The precedence rule is undocumented in the builder API and easy to get wrong from a Rust consumer that calls `with_settings` last. Either (a) document the precedence on `with_settings`, or (b) have it only fill the dedicated fields when they are still `None`, so explicit setters always win regardless of call order.

In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 910-952: `prepare_document_delete` re-implements the nonce-alloc/sign/validate/rollback recipe that already lives in rs-sdk for create/replace
  `prepare_document_create` and `prepare_document_replace` delegate to `build_signed_document_create_transition` / `build_signed_document_replace_transition`, which encapsulate (a) explicit nonce allocation, (b) `sign_with_nonce`, (c) `ensure_valid_state_transition_structure`, and (d) `rollback_identity_contract_nonce` on any pre-broadcast error. `prepare_document_delete` reimplements that recipe inline here, and `packages/rs-sdk-ffi/src/document/delete.rs:158` repeats it a third time. Three independent copies of a consensus-adjacent rollback contract is a drift hazard — if the rollback policy changes (e.g. adding tracing, a conditional refresh, or a structural retry) at least one site will be missed. Promote a `build_signed_document_delete_transition` helper next to the create/replace helpers in `packages/rs-sdk/src/platform/transition/put_document.rs` (or in the delete builder module) and funnel all three call sites through it.

In `packages/rs-sdk/src/sdk.rs`:
- [SUGGESTION] lines 502-514: Rollback API is identity-contract-only — note this or add the sibling for plain identity nonces before more transitions adopt prepare flows
  `Sdk::rollback_identity_contract_nonce` is the new pre-broadcast rollback primitive, but the analogous identity-nonce path (`refresh_identity_nonce` / `nonce_cache.get_identity_nonce`) has no rollback. Document Batch transitions only consume identity-contract nonces today, so the asymmetry is currently fine. Once prepare-style APIs land for transitions that consume the plain identity nonce (identity update, credit transfer, contract create/update — all of which already share the same `get_or_fetch_nonce` machinery in `internal_cache/mod.rs:319`), they will silently leak local identity nonces on pre-broadcast failures unless the equivalent rollback exists. Either add `rollback_identity_nonce` now next to its sibling, or add a doc-comment noting that the rollback API is intentionally identity-contract-only until those transitions are converted.

Comment on lines +223 to +224
#[allow(clippy::too_many_arguments)]
async fn build_signed_document_create_or_replace_transition<S: Signer<IdentityPublicKey>>(
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: build_signed_document_create_or_replace_transition was demoted from pub to private — source-breaking for native callers

Git history confirms this commit changed pub async fn build_signed_document_create_or_replace_transition to a private async fn. The enclosing platform::transition::put_document module is still pub, so this is a source-breaking change for downstream Rust consumers that were using the helper to prepare a signed document transition with revision-driven create/replace dispatch (and the legacy entropy = None fallback) without broadcasting. The new public replacements do not cover that exact surface: build_signed_document_create_transition requires the caller to commit to the create intent up front and supply matching entropy, build_signed_document_replace_transition is replace-only, and PutDocument::put_to_platform broadcasts immediately. If the visibility reduction is intentional, mention it in the PR notes and provide a migration path; otherwise restore pub and keep the strict helpers as the recommended entry points.

💡 Suggested change
Suggested change
#[allow(clippy::too_many_arguments)]
async fn build_signed_document_create_or_replace_transition<S: Signer<IdentityPublicKey>>(
#[allow(clippy::too_many_arguments)]
pub async fn build_signed_document_create_or_replace_transition<S: Signer<IdentityPublicKey>>(

source: ['codex']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 223-224: `build_signed_document_create_or_replace_transition` was demoted from `pub` to private — source-breaking for native callers
  Git history confirms this commit changed `pub async fn build_signed_document_create_or_replace_transition` to a private `async fn`. The enclosing `platform::transition::put_document` module is still `pub`, so this is a source-breaking change for downstream Rust consumers that were using the helper to prepare a signed document transition with revision-driven create/replace dispatch (and the legacy `entropy = None` fallback) without broadcasting. The new public replacements do not cover that exact surface: `build_signed_document_create_transition` requires the caller to commit to the create intent up front and supply matching entropy, `build_signed_document_replace_transition` is replace-only, and `PutDocument::put_to_platform` broadcasts immediately. If the visibility reduction is intentional, mention it in the PR notes and provide a migration path; otherwise restore `pub` and keep the strict helpers as the recommended entry points.

Comment on lines 127 to 137
pub fn with_settings(mut self, settings: PutSettings) -> Self {
if let Some(user_fee_increase) = settings.user_fee_increase {
self.user_fee_increase = Some(user_fee_increase);
}
if let Some(state_transition_creation_options) = settings.state_transition_creation_options
{
self.state_transition_creation_options = Some(state_transition_creation_options);
}
self.settings = Some(settings);
self
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: with_settings silently overwrites explicit with_user_fee_increase / with_state_transition_creation_options values

with_settings now unconditionally copies settings.user_fee_increase and settings.state_transition_creation_options into the dedicated builder fields whenever they are Some. This makes the final value order-dependent: builder.with_user_fee_increase(42).with_settings(s_with_7) ends at 7, while builder.with_settings(s_with_7).with_user_fee_increase(42) ends at 42. The FFI path at packages/rs-sdk-ffi/src/document/delete.rs:146-152 actually depends on the second ordering. The precedence rule is undocumented in the builder API and easy to get wrong from a Rust consumer that calls with_settings last. Either (a) document the precedence on with_settings, or (b) have it only fill the dedicated fields when they are still None, so explicit setters always win regardless of call order.

source: ['claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/documents/transitions/delete.rs`:
- [SUGGESTION] lines 127-137: `with_settings` silently overwrites explicit `with_user_fee_increase` / `with_state_transition_creation_options` values
  `with_settings` now unconditionally copies `settings.user_fee_increase` and `settings.state_transition_creation_options` into the dedicated builder fields whenever they are `Some`. This makes the final value order-dependent: `builder.with_user_fee_increase(42).with_settings(s_with_7)` ends at 7, while `builder.with_settings(s_with_7).with_user_fee_increase(42)` ends at 42. The FFI path at `packages/rs-sdk-ffi/src/document/delete.rs:146-152` actually depends on the second ordering. The precedence rule is undocumented in the builder API and easy to get wrong from a Rust consumer that calls `with_settings` last. Either (a) document the precedence on `with_settings`, or (b) have it only fill the dedicated fields when they are still `None`, so explicit setters always win regardless of call order.

Comment on lines +910 to +952
// Pre-allocate the identity-contract nonce so that any pre-broadcast
// failure (sign or local structure validation) can be rolled back via
// rs-sdk's `rollback_identity_contract_nonce`. The rollback is
// conditional: it only adjusts the cache entry if it still equals the
// nonce allocated here, so it does not clobber concurrent allocations.
let allocated_nonce = self
.inner_sdk()
.get_identity_contract_nonce(owner_id, contract_id, true, settings)
.await?;

let state_transition = match builder
.sign_with_nonce(
allocated_nonce,
&identity_key,
&signer,
self.inner_sdk().version(),
)
.await
{
Ok(st) => st,
Err(err) => {
self.inner_sdk()
.rollback_identity_contract_nonce(owner_id, contract_id, allocated_nonce)
.await;
return Err(err.into());
}
};

// Validate structure before handing the ST back, mirroring rs-sdk's
// pre-broadcast check. For document Batch transitions this currently
// ends up as a no-op because DPP returns UnsupportedFeatureError until
// that structure validation is implemented there.
if let Err(err) =
ensure_valid_state_transition_structure(&state_transition, self.inner_sdk().version())
{
self.inner_sdk()
.rollback_identity_contract_nonce(owner_id, contract_id, allocated_nonce)
.await;
return Err(err.into());
}

Ok(state_transition.into())
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: prepare_document_delete re-implements the nonce-alloc/sign/validate/rollback recipe that already lives in rs-sdk for create/replace

prepare_document_create and prepare_document_replace delegate to build_signed_document_create_transition / build_signed_document_replace_transition, which encapsulate (a) explicit nonce allocation, (b) sign_with_nonce, (c) ensure_valid_state_transition_structure, and (d) rollback_identity_contract_nonce on any pre-broadcast error. prepare_document_delete reimplements that recipe inline here, and packages/rs-sdk-ffi/src/document/delete.rs:158 repeats it a third time. Three independent copies of a consensus-adjacent rollback contract is a drift hazard — if the rollback policy changes (e.g. adding tracing, a conditional refresh, or a structural retry) at least one site will be missed. Promote a build_signed_document_delete_transition helper next to the create/replace helpers in packages/rs-sdk/src/platform/transition/put_document.rs (or in the delete builder module) and funnel all three call sites through it.

source: ['claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 910-952: `prepare_document_delete` re-implements the nonce-alloc/sign/validate/rollback recipe that already lives in rs-sdk for create/replace
  `prepare_document_create` and `prepare_document_replace` delegate to `build_signed_document_create_transition` / `build_signed_document_replace_transition`, which encapsulate (a) explicit nonce allocation, (b) `sign_with_nonce`, (c) `ensure_valid_state_transition_structure`, and (d) `rollback_identity_contract_nonce` on any pre-broadcast error. `prepare_document_delete` reimplements that recipe inline here, and `packages/rs-sdk-ffi/src/document/delete.rs:158` repeats it a third time. Three independent copies of a consensus-adjacent rollback contract is a drift hazard — if the rollback policy changes (e.g. adding tracing, a conditional refresh, or a structural retry) at least one site will be missed. Promote a `build_signed_document_delete_transition` helper next to the create/replace helpers in `packages/rs-sdk/src/platform/transition/put_document.rs` (or in the delete builder module) and funnel all three call sites through it.

Comment on lines 434 to 495
@@ -58,64 +441,56 @@ impl<S: Signer<IdentityPublicKey>> PutDocument<S> for Document {
signer: &S,
settings: Option<PutSettings>,
) -> Result<StateTransition, Error> {
let new_identity_contract_nonce = sdk
.get_identity_contract_nonce(
self.owner_id(),
document_type.data_contract_id(),
true,
settings,
)
.await?;

let settings = settings.unwrap_or_default();
let transition = if self.revision().is_some()
&& self.revision().unwrap() != INITIAL_REVISION
{
BatchTransition::new_document_replacement_transition_from_document(
self.clone(),
document_type.as_ref(),
// Route through the strict create/replace helpers so callers get the
// same fail-fast revision-vs-intent guarantees as the wasm-sdk
// `prepareDocumentCreate` / `prepareDocumentReplace` paths. The
// dispatch is driven by the document revision: unset or
// `INITIAL_REVISION` selects create; revisions strictly greater than
// `INITIAL_REVISION` select replace; `Some(0)` is rejected by the
// strict replace helper before any nonce allocation.
let transition = if self.revision().is_none() || self.revision() == Some(INITIAL_REVISION) {
// Create path. Preserve legacy behavior: when the caller did not
// supply entropy, generate it and rewrite `document.id` so the
// pair stays consistent before we hand the (document, entropy)
// to the strict create helper. The strict helper still verifies
// that `document.id == generate_document_id_v0(entropy)` before
// allocating any nonce, so the legacy fallback cannot mask an
// id/entropy mismatch.
let (resolved_document, resolved_entropy) = resolve_document_create_entropy(
self,
&document_type,
document_state_transition_entropy,
);
build_signed_document_create_transition(
sdk,
&resolved_document,
&document_type,
resolved_entropy,
&identity_public_key,
new_identity_contract_nonce,
settings.user_fee_increase.unwrap_or_default(),
token_payment_info,
signer,
sdk.version(),
settings.state_transition_creation_options,
settings,
)
.await?
} else {
let (document, document_state_transition_entropy) = document_state_transition_entropy
.map(|entropy| (self.clone(), entropy))
.unwrap_or_else(|| {
let mut rng = StdRng::from_entropy();
let mut document = self.clone();
let entropy = rng.gen::<[u8; 32]>();
document.set_id(Document::generate_document_id_v0(
&document_type.data_contract_id(),
&document.owner_id(),
document_type.name(),
entropy.as_slice(),
));
(document, entropy)
});
BatchTransition::new_document_creation_transition_from_document(
document,
document_type.as_ref(),
document_state_transition_entropy,
// Replace path: entropy is unused; the strict helper enforces
// `revision > INITIAL_REVISION`.
build_signed_document_replace_transition(
sdk,
self,
&document_type,
&identity_public_key,
new_identity_contract_nonce,
settings.user_fee_increase.unwrap_or_default(),
token_payment_info,
signer,
sdk.version(),
settings.state_transition_creation_options,
settings,
)
.await?
};
ensure_valid_state_transition_structure(&transition, sdk.version())?;

// response is empty for a broadcast, result comes from the stream wait for state transition result
transition.broadcast(sdk, Some(settings)).await?;
transition
.broadcast(sdk, Some(settings.unwrap_or_default()))
.await?;
Ok(transition)
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Legacy PutDocument::put_to_platform now rejects Some(entropy) whose derived id ≠ document.id locally — undocumented compat change

Before this PR, PutDocument::put_to_platform with document_state_transition_entropy = Some(e) passed the entropy and the caller's document.id through to BatchTransition::new_document_creation_transition_from_document unchecked; mismatches were rejected server-side. After the refactor, put_to_platform routes the create path through build_signed_document_create_transition (line 464), whose ensure_document_id_matches_entropy guard rejects mismatches locally with Error::Generic("InvalidArgument: document.id does not match generate_document_id_v0(...)"). In-tree callers (rs-platform-wallet profile creation, etc.) all pass None, which still flows through resolve_document_create_entropy and is unaffected. But external rs-sdk consumers that built document.id themselves and supplied entropy explicitly will now hit a hard local error where the old code failed remotely. The new behavior is strictly more correct; it just isn't called out alongside the documented revision tightening. Add a line to the compat note in the module docstring (and the PR description) covering the entropy/id tightening for Some(entropy) callers.

source: ['claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 434-495: Legacy `PutDocument::put_to_platform` now rejects `Some(entropy)` whose derived id ≠ `document.id` locally — undocumented compat change
  Before this PR, `PutDocument::put_to_platform` with `document_state_transition_entropy = Some(e)` passed the entropy and the caller's `document.id` through to `BatchTransition::new_document_creation_transition_from_document` unchecked; mismatches were rejected server-side. After the refactor, `put_to_platform` routes the create path through `build_signed_document_create_transition` (line 464), whose `ensure_document_id_matches_entropy` guard rejects mismatches locally with `Error::Generic("InvalidArgument: document.id does not match generate_document_id_v0(...)")`. In-tree callers (`rs-platform-wallet` profile creation, etc.) all pass `None`, which still flows through `resolve_document_create_entropy` and is unaffected. But external rs-sdk consumers that built `document.id` themselves and supplied entropy explicitly will now hit a hard local error where the old code failed remotely. The new behavior is strictly more correct; it just isn't called out alongside the documented revision tightening. Add a line to the compat note in the module docstring (and the PR description) covering the entropy/id tightening for `Some(entropy)` callers.

Comment on lines +1324 to +1358
fn ensure_document_create_revision(
revision: Option<u64>,
replace_api_name: &str,
) -> Result<(), WasmSdkError> {
if let Some(revision) = revision {
if revision != INITIAL_REVISION {
return Err(WasmSdkError::invalid_argument(format!(
"Document revision is {} but create requires revision to be unset or {}. Use {} for existing documents.",
revision, INITIAL_REVISION, replace_api_name,
)));
}
}

Ok(())
}

fn ensure_document_replace_revision(
revision: Option<u64>,
create_api_name: &str,
) -> Result<(), WasmSdkError> {
let revision = revision.ok_or_else(|| {
WasmSdkError::invalid_argument(format!(
"Document must have a revision set for replace. Use {} for new documents.",
create_api_name,
))
})?;

if revision <= INITIAL_REVISION {
return Err(WasmSdkError::invalid_argument(format!(
"Document revision is {} but replace requires revision > {}. Use {} for new documents.",
revision, INITIAL_REVISION, create_api_name,
)));
}

Ok(())
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Revision=0 callers bounce between documentCreatedocumentReplace error messages without ever learning the real fix

ensure_document_create_revision with Some(0) errors with "...Use {replace_api_name} for existing documents." ensure_document_replace_revision then rejects Some(0) (<= INITIAL_REVISION) with the symmetric "...Use {create_api_name} for new documents." A confused caller passing revision: 0 bounces between the two APIs without ever being told that revision 0 is always invalid (use unset or 1 for create, > 1 for replace). The rs-sdk side already has ensure_revision_nonzero (put_document.rs:50) emitting a clear "revision 0 is invalid" message before the per-intent guards. Either special-case Some(0) in both wasm-sdk helpers with the same message, or have them delegate to a shared fail-fast helper so users get the actual fix on the first error.

source: ['claude']

Comment on lines +272 to +275
if allocated_nonce == 0 {
// Nothing to roll back; bumping never produces 0 (it starts at 1).
return;
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: allocated_nonce == 0 early-return in rollback_identity_contract_nonce is unreachable for any allocator-produced value

The doc-comment on rollback_identity_contract_nonce (and the Sdk wrapper) instructs callers to invoke it only after a successful get_identity_contract_nonce(.., bump_first = true). bump_nonce(0) returns 1 (line 118), so any value produced by that allocation pattern is >= 1. The if allocated_nonce == 0 { return; } guard therefore only ever fires on caller misuse (e.g. passing the raw platform nonce without bumping). Either drop the guard and rely on the current_nonce == allocated_nonce check below (which already wouldn't roll back to -1), or rewrite the inline comment to frame the guard as a misuse-defense rather than a value the allocator can actually produce — the tracing::trace! branch as written is unreachable for the 0 case.

source: ['claude']

Comment on lines +396 to +415
} else {
let (doc, entropy) = resolve_document_create_entropy(
document,
document_type,
document_state_transition_entropy,
);
BatchTransition::new_document_creation_transition_from_document(
doc,
document_type.as_ref(),
entropy,
identity_public_key,
new_identity_contract_nonce,
put_settings.user_fee_increase.unwrap_or_default(),
token_payment_info,
signer,
sdk.version(),
put_settings.state_transition_creation_options,
)
.await?
};
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: None-entropy create branch in the inner dispatcher is unreachable from any current public caller

build_and_sign_create_or_replace_after_nonce still carries the resolve_document_create_entropy(...) fallback that regenerates entropy and rewrites the document id on the create branch when document_state_transition_entropy is None. After this PR every caller of the private dispatcher passes through one of the strict helpers: build_signed_document_create_transition always supplies Some(entropy) (after ensure_document_id_matches_entropy), build_signed_document_replace_transition always takes the replace branch (entropy unused), and the legacy PutDocument::put_to_platform pre-resolves entropy via resolve_document_create_entropy before delegating. The None-entropy create branch is therefore dead from any current call site; it is defensible as a guard against future internal callers but costs a clone+RNG path and obscures the real contract. Either drop the fallback and tighten the parameter to [u8; 32] at this layer, or add a comment that the None branch is reserved for the legacy trait.

source: ['claude']

// Reject the always-invalid `Some(0)` revision before allocating any
// nonce. Strict create/replace intent validation is the job of the
// dedicated helpers below.
ensure_revision_nonzero(document.revision())?;
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: ensure_revision_nonzero in the inner dispatcher is redundant once both strict wrappers run first

Every public path into build_signed_document_create_or_replace_transition now first runs ensure_revision_for_create or ensure_revision_for_replace, both of which explicitly reject Some(0) (lines 67–75 and 83–94). The ensure_revision_nonzero(document.revision())? call at the top of the dispatcher therefore cannot be reached with Some(0). As written it is defense-in-depth, but it adds an invariant a future maintainer has to keep in sync with the two strict guards. Either remove it or convert it to a debug_assert!, so the strict wrappers remain the single source of truth for create/replace revision policy.

source: ['claude']

Comment on lines +353 to +379
#[derive(Debug)]
struct AlwaysFailingSigner;

#[async_trait::async_trait]
impl dpp::identity::signer::Signer<IdentityPublicKey> for AlwaysFailingSigner {
async fn sign(
&self,
_key: &IdentityPublicKey,
_data: &[u8],
) -> Result<dpp::platform_value::BinaryData, dpp::ProtocolError> {
Err(dpp::ProtocolError::Generic(
"deliberate signing failure for delete rollback test".to_string(),
))
}

async fn sign_create_witness(
&self,
_key: &IdentityPublicKey,
_data: &[u8],
) -> Result<dpp::address_funds::AddressWitness, dpp::ProtocolError> {
unreachable!("not used by document delete transition signing")
}

fn can_sign_with(&self, _key: &IdentityPublicKey) -> bool {
true
}
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: AlwaysFailingSigner test fixture is duplicated across delete.rs and put_document.rs

The same AlwaysFailingSigner test fixture (identical unreachable! markers and explanatory comments) is reproduced verbatim in both packages/rs-sdk/src/platform/documents/transitions/delete.rs and packages/rs-sdk/src/platform/transition/put_document.rs (~line 600). Both copies will need to be updated in lockstep if the Signer trait gains a method or any of the unreachable! paths becomes reachable. Promote the fixture to a #[cfg(test)] pub(crate) module so both rollback tests share one implementation; this also makes the intent ("deterministic post-allocation signing failure") discoverable in one place.

source: ['claude']

Comment on lines +502 to +514
/// For broadcast failures keep using
/// [`refresh_identity_nonce`](Self::refresh_identity_nonce), which
/// preserves the cached (bumped) value so the cache cannot regress past
/// a nonce the network may have accepted.
pub async fn rollback_identity_contract_nonce(
&self,
identity_id: Identifier,
contract_id: Identifier,
allocated_nonce: IdentityNonce,
) {
self.nonce_cache
.rollback_identity_contract_nonce(identity_id, contract_id, allocated_nonce)
.await;
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Rollback API is identity-contract-only — note this or add the sibling for plain identity nonces before more transitions adopt prepare flows

Sdk::rollback_identity_contract_nonce is the new pre-broadcast rollback primitive, but the analogous identity-nonce path (refresh_identity_nonce / nonce_cache.get_identity_nonce) has no rollback. Document Batch transitions only consume identity-contract nonces today, so the asymmetry is currently fine. Once prepare-style APIs land for transitions that consume the plain identity nonce (identity update, credit transfer, contract create/update — all of which already share the same get_or_fetch_nonce machinery in internal_cache/mod.rs:319), they will silently leak local identity nonces on pre-broadcast failures unless the equivalent rollback exists. Either add rollback_identity_nonce now next to its sibling, or add a doc-comment noting that the rollback API is intentionally identity-contract-only until those transitions are converted.

source: ['claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/sdk.rs`:
- [SUGGESTION] lines 502-514: Rollback API is identity-contract-only — note this or add the sibling for plain identity nonces before more transitions adopt prepare flows
  `Sdk::rollback_identity_contract_nonce` is the new pre-broadcast rollback primitive, but the analogous identity-nonce path (`refresh_identity_nonce` / `nonce_cache.get_identity_nonce`) has no rollback. Document Batch transitions only consume identity-contract nonces today, so the asymmetry is currently fine. Once prepare-style APIs land for transitions that consume the plain identity nonce (identity update, credit transfer, contract create/update — all of which already share the same `get_or_fetch_nonce` machinery in `internal_cache/mod.rs:319`), they will silently leak local identity nonces on pre-broadcast failures unless the equivalent rollback exists. Either add `rollback_identity_nonce` now next to its sibling, or add a doc-comment noting that the rollback API is intentionally identity-contract-only until those transitions are converted.

Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Verified two-phase prepare APIs and rs-sdk nonce-rollback helpers against the worktree at 8c607ed. No blocking issues survive verification — codex's revision/DocumentFactory blocker is a false positive (the wasm-sdk create path goes through BatchTransition::new_document_creation_transition_from_document → DocumentCreateTransitionV0::from_document, which never reads document.revision(); DocumentFactory is not on this path). Remaining valid items are architecture/docs suggestions around stringly-typed errors, public API surface, and minor duplication.

Reviewed commit: 8c607ed

🟡 4 suggestion(s) | 💬 2 nitpick(s)

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 63-143: Stringly-typed `Error::Generic("InvalidArgument: …")` for new validation helpers
  `ensure_revision_nonzero`, `ensure_revision_for_create`, `ensure_revision_for_replace`, and `ensure_document_id_matches_entropy` all surface caller-input failures by prepending `"InvalidArgument: "` into `Error::Generic(String)`. These are user-facing error modes (`DocumentWasm` lets JS callers set `id` and `entropy` independently via `constructor` + `set_entropy()`, and revision is caller-supplied), so they will routinely bubble through `From<SdkError> for WasmSdkError` as `WasmSdkErrorKind::Generic` — not `InvalidArgument` like the wasm-sdk-local guards. The `Error` enum in `packages/rs-sdk/src/error.rs` already carries `// TODO: Use domain specific errors instead of generic ones` next to `Generic(String)`, and typed variants like `AlreadyExists`/`InvalidCreditTransfer`/`IdentityNonceNotFound` exist. Add an `Error::InvalidArgument(String)` variant (or a typed `InvalidDocumentRevision { … }`) so wasm-sdk and FFI consumers can pattern-match instead of substring-matching the message prefix.
- [SUGGESTION] lines 241-287: `build_signed_document_create_or_replace_transition` left `pub` but only enforces the weak `ensure_revision_nonzero` guard
  The dispatch helper is kept `pub` for source compatibility with downstream native callers, but a workspace grep finds no in-tree caller besides the file itself — `PutDocument::put_to_platform` (the path `rs-platform-wallet` uses) now routes through the strict create/replace helpers, not this dispatcher. Because it only runs `ensure_revision_nonzero` and routes by `is_document_replace_revision`, a future contributor reaching for it directly silently gets the weaker validation (e.g. `revision = Some(5)` on what was meant to be a create silently becomes a replace). Either demote to `pub(crate)`, rename (e.g. `..._dispatch`/`..._legacy`) so the call-site reads self-warning, or apply `#[deprecated(note="use build_signed_document_{create,replace}_transition")]`. If a real downstream caller exists in another repo, the doc comment should name it concretely.
- [SUGGESTION] lines 1-37: PR/release notes should call out the rs-sdk `PutDocument::put_to_platform` tightening, not just wasm-sdk
  The module-level doc and the `put_to_platform` doc both note the behavior change, but downstream Rust SDK consumers reading only the PR description/changelog will see only the wasm-sdk `documentCreate`/`documentReplace` note. `PutDocument::put_to_platform` is now stricter in two ways too: (1) `revision = Some(0)` is rejected by `ensure_revision_for_replace` (previously routed to the create branch because `0 > INITIAL_REVISION` is false), and (2) when callers pass `Some(entropy)` on the create path, `ensure_document_id_matches_entropy` now rejects mismatches before nonce allocation. The only in-tree native caller (`rs-platform-wallet/src/wallet/identity/network/profile.rs`) uses `revision: None` on create and `Some(current_revision + 1)` on update so it's unaffected, but other downstream Rust SDK users could be surprised. Amend the PR description / release notes to call out the rs-sdk-side tightening alongside the wasm-sdk one.

In `packages/rs-sdk/src/platform/documents/transitions/delete.rs`:
- [SUGGESTION] lines 135-150: `with_settings` produces two sources of truth for `user_fee_increase`/`state_transition_creation_options`
  `with_settings` copies `settings.user_fee_increase` and `settings.state_transition_creation_options` into the builder's own `Option` fields only when those fields are `None`, and *also* stores the whole `settings` on `self.settings`. The result is that those two fields are now read from the builder field for signing while the rest of `PutSettings` continues to be read via `self.settings`. A later refactor that reads `self.settings.user_fee_increase` to recover the effective override will silently see the original `settings` value, not the explicit `with_user_fee_increase` override that won at sign time. Pick one source of truth: either merge *into* `self.settings` (so `self.settings.user_fee_increase` always reflects the effective value), or never store `settings` whole and project just the fields the builder needs.

Comment on lines +63 to +143
fn ensure_revision_nonzero(revision: Option<u64>) -> Result<(), Error> {
if matches!(revision, Some(0)) {
return Err(Error::Generic(
"InvalidArgument: document revision 0 is invalid; \
use unset or 1 (INITIAL_REVISION) for create, or > 1 for replace"
.to_string(),
));
}
Ok(())
}

/// Strict revision guard for the document **create** path.
///
/// Accepts `None` and `Some(INITIAL_REVISION)`. Rejects `Some(0)` and any
/// revision strictly greater than `INITIAL_REVISION`. This is the rs-sdk-side
/// fail-fast equivalent of the wasm-sdk `ensureDocumentCreateRevision` guard.
fn ensure_revision_for_create(revision: Option<u64>) -> Result<(), Error> {
match revision {
None => Ok(()),
Some(rev) if rev == INITIAL_REVISION => Ok(()),
Some(rev) => Err(Error::Generic(format!(
"InvalidArgument: document revision is {rev} but create requires revision \
to be unset or {INITIAL_REVISION}; use the replace path for revisions > {INITIAL_REVISION}"
))),
}
}

/// Strict revision guard for the document **replace** path.
///
/// Accepts only `Some(rev)` with `rev > INITIAL_REVISION`. Rejects `None`,
/// `Some(0)`, and `Some(INITIAL_REVISION)`. This is the rs-sdk-side fail-fast
/// equivalent of the wasm-sdk `ensureDocumentReplaceRevision` guard.
fn ensure_revision_for_replace(revision: Option<u64>) -> Result<(), Error> {
match revision {
Some(rev) if rev > INITIAL_REVISION => Ok(()),
Some(rev) => Err(Error::Generic(format!(
"InvalidArgument: document revision is {rev} but replace requires revision > \
{INITIAL_REVISION}; use the create path for new documents"
))),
None => Err(Error::Generic(
"InvalidArgument: document must have a revision set for replace; \
use the create path for new documents"
.to_string(),
)),
}
}

/// Strict create-path id check: documents handed to
/// [`build_signed_document_create_transition`] must already have their `id`
/// derived from the supplied entropy via [`Document::generate_document_id_v0`].
///
/// This guards against silently signing a transition whose committed
/// document id does not match the entropy bound into the create transition.
/// Callers that want id auto-generation should use the legacy
/// [`PutDocument::put_to_platform`] trait method, which still accepts
/// `entropy = None` and rewrites the document id before signing.
fn ensure_document_id_matches_entropy(
document: &Document,
document_type: &DocumentType,
entropy: &[u8; 32],
) -> Result<(), Error> {
let expected = Document::generate_document_id_v0(
&document_type.data_contract_id(),
&document.owner_id(),
document_type.name(),
entropy.as_slice(),
);
if document.id() != expected {
return Err(Error::Generic(format!(
"InvalidArgument: document.id does not match \
generate_document_id_v0(contract_id, owner_id, document_type_name, entropy); \
expected {expected}, got {got}. \
Either set document.id to the derived value before calling \
build_signed_document_create_transition, or use the legacy \
PutDocument::put_to_platform trait method which auto-generates \
entropy and rewrites the document id when entropy is None.",
got = document.id()
)));
}
Ok(())
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Stringly-typed Error::Generic("InvalidArgument: …") for new validation helpers

ensure_revision_nonzero, ensure_revision_for_create, ensure_revision_for_replace, and ensure_document_id_matches_entropy all surface caller-input failures by prepending "InvalidArgument: " into Error::Generic(String). These are user-facing error modes (DocumentWasm lets JS callers set id and entropy independently via constructor + set_entropy(), and revision is caller-supplied), so they will routinely bubble through From<SdkError> for WasmSdkError as WasmSdkErrorKind::Generic — not InvalidArgument like the wasm-sdk-local guards. The Error enum in packages/rs-sdk/src/error.rs already carries // TODO: Use domain specific errors instead of generic ones next to Generic(String), and typed variants like AlreadyExists/InvalidCreditTransfer/IdentityNonceNotFound exist. Add an Error::InvalidArgument(String) variant (or a typed InvalidDocumentRevision { … }) so wasm-sdk and FFI consumers can pattern-match instead of substring-matching the message prefix.

source: ['claude', 'codex']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 63-143: Stringly-typed `Error::Generic("InvalidArgument: …")` for new validation helpers
  `ensure_revision_nonzero`, `ensure_revision_for_create`, `ensure_revision_for_replace`, and `ensure_document_id_matches_entropy` all surface caller-input failures by prepending `"InvalidArgument: "` into `Error::Generic(String)`. These are user-facing error modes (`DocumentWasm` lets JS callers set `id` and `entropy` independently via `constructor` + `set_entropy()`, and revision is caller-supplied), so they will routinely bubble through `From<SdkError> for WasmSdkError` as `WasmSdkErrorKind::Generic` — not `InvalidArgument` like the wasm-sdk-local guards. The `Error` enum in `packages/rs-sdk/src/error.rs` already carries `// TODO: Use domain specific errors instead of generic ones` next to `Generic(String)`, and typed variants like `AlreadyExists`/`InvalidCreditTransfer`/`IdentityNonceNotFound` exist. Add an `Error::InvalidArgument(String)` variant (or a typed `InvalidDocumentRevision { … }`) so wasm-sdk and FFI consumers can pattern-match instead of substring-matching the message prefix.

Comment on lines +241 to +287
pub async fn build_signed_document_create_or_replace_transition<S: Signer<IdentityPublicKey>>(
sdk: &Sdk,
document: &Document,
document_type: &DocumentType,
document_state_transition_entropy: Option<[u8; 32]>,
identity_public_key: &IdentityPublicKey,
token_payment_info: Option<TokenPaymentInfo>,
signer: &S,
settings: Option<PutSettings>,
) -> Result<StateTransition, Error> {
// Reject the always-invalid `Some(0)` revision before allocating any
// nonce. Strict create/replace intent validation is the job of the
// dedicated helpers below.
ensure_revision_nonzero(document.revision())?;

let owner_id = document.owner_id();
let contract_id = document_type.data_contract_id();
let new_identity_contract_nonce = sdk
.get_identity_contract_nonce(owner_id, contract_id, true, settings)
.await?;

let result = build_and_sign_create_or_replace_after_nonce(
sdk,
document,
document_type,
document_state_transition_entropy,
identity_public_key,
token_payment_info,
signer,
settings,
new_identity_contract_nonce,
)
.await;

match result {
Ok(transition) => Ok(transition),
Err(err) => {
sdk.rollback_identity_contract_nonce(
owner_id,
contract_id,
new_identity_contract_nonce,
)
.await;
Err(err)
}
}
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: build_signed_document_create_or_replace_transition left pub but only enforces the weak ensure_revision_nonzero guard

The dispatch helper is kept pub for source compatibility with downstream native callers, but a workspace grep finds no in-tree caller besides the file itself — PutDocument::put_to_platform (the path rs-platform-wallet uses) now routes through the strict create/replace helpers, not this dispatcher. Because it only runs ensure_revision_nonzero and routes by is_document_replace_revision, a future contributor reaching for it directly silently gets the weaker validation (e.g. revision = Some(5) on what was meant to be a create silently becomes a replace). Either demote to pub(crate), rename (e.g. ..._dispatch/..._legacy) so the call-site reads self-warning, or apply #[deprecated(note="use build_signed_document_{create,replace}_transition")]. If a real downstream caller exists in another repo, the doc comment should name it concretely.

source: ['claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 241-287: `build_signed_document_create_or_replace_transition` left `pub` but only enforces the weak `ensure_revision_nonzero` guard
  The dispatch helper is kept `pub` for source compatibility with downstream native callers, but a workspace grep finds no in-tree caller besides the file itself — `PutDocument::put_to_platform` (the path `rs-platform-wallet` uses) now routes through the strict create/replace helpers, not this dispatcher. Because it only runs `ensure_revision_nonzero` and routes by `is_document_replace_revision`, a future contributor reaching for it directly silently gets the weaker validation (e.g. `revision = Some(5)` on what was meant to be a create silently becomes a replace). Either demote to `pub(crate)`, rename (e.g. `..._dispatch`/`..._legacy`) so the call-site reads self-warning, or apply `#[deprecated(note="use build_signed_document_{create,replace}_transition")]`. If a real downstream caller exists in another repo, the doc comment should name it concretely.

Comment on lines 135 to 150
pub fn with_settings(mut self, settings: PutSettings) -> Self {
if self.user_fee_increase.is_none() {
if let Some(user_fee_increase) = settings.user_fee_increase {
self.user_fee_increase = Some(user_fee_increase);
}
}
if self.state_transition_creation_options.is_none() {
if let Some(state_transition_creation_options) =
settings.state_transition_creation_options
{
self.state_transition_creation_options = Some(state_transition_creation_options);
}
}
self.settings = Some(settings);
self
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: with_settings produces two sources of truth for user_fee_increase/state_transition_creation_options

with_settings copies settings.user_fee_increase and settings.state_transition_creation_options into the builder's own Option fields only when those fields are None, and also stores the whole settings on self.settings. The result is that those two fields are now read from the builder field for signing while the rest of PutSettings continues to be read via self.settings. A later refactor that reads self.settings.user_fee_increase to recover the effective override will silently see the original settings value, not the explicit with_user_fee_increase override that won at sign time. Pick one source of truth: either merge into self.settings (so self.settings.user_fee_increase always reflects the effective value), or never store settings whole and project just the fields the builder needs.

source: ['claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/documents/transitions/delete.rs`:
- [SUGGESTION] lines 135-150: `with_settings` produces two sources of truth for `user_fee_increase`/`state_transition_creation_options`
  `with_settings` copies `settings.user_fee_increase` and `settings.state_transition_creation_options` into the builder's own `Option` fields only when those fields are `None`, and *also* stores the whole `settings` on `self.settings`. The result is that those two fields are now read from the builder field for signing while the rest of `PutSettings` continues to be read via `self.settings`. A later refactor that reads `self.settings.user_fee_increase` to recover the effective override will silently see the original `settings` value, not the explicit `with_user_fee_increase` override that won at sign time. Pick one source of truth: either merge *into* `self.settings` (so `self.settings.user_fee_increase` always reflects the effective value), or never store `settings` whole and project just the fields the builder needs.

Comment on lines +1 to +37
//! Document put / create / replace state-transition builders.
//!
//! # Compatibility note (2026-05)
//!
//! Two intentionally different create-path entry points coexist:
//!
//! - The [`PutDocument::put_to_platform`] trait method is the **legacy
//! native** entry point. It accepts
//! `document_state_transition_entropy = None` on the create path and will
//! auto-generate 32-byte entropy + rewrite `document.id` via
//! [`Document::generate_document_id_v0`] before signing. In-tree callers
//! such as `rs-platform-wallet` (DashPay profile creation) rely on this
//! fallback.
//! - The strict [`build_signed_document_create_transition`] /
//! [`build_signed_document_replace_transition`] helpers, used by the
//! wasm-sdk `prepareDocumentCreate` / `prepareDocumentReplace` flows, do
//! **not** auto-generate entropy. Callers must supply entropy whose
//! derived `Document::generate_document_id_v0(...)` matches `document.id`;
//! a mismatch is rejected before any identity-contract nonce is
//! allocated.
//!
//! New prepare/sign-without-broadcast call sites should prefer the strict
//! builders so the supplied document id and entropy commit to the same
//! value.
//!
//! Additionally, [`PutDocument::put_to_platform`]'s create path now
//! locally rejects when `document_state_transition_entropy = Some(entropy)`
//! and the derived id does not match `document.id` — the strict
//! [`build_signed_document_create_transition`] helper that backs it
//! enforces this id-matches-entropy check before any nonce allocation.
//! Passing `None` still preserves the legacy auto-generate-entropy /
//! rewrite-id behavior for callers that opt into it.
//!
//! [`build_signed_document_create_or_replace_transition`] remains public
//! for source compatibility with downstream native callers that depended
//! on it before the strict helpers were introduced. New callers should
//! prefer the strict create/replace helpers above.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: PR/release notes should call out the rs-sdk PutDocument::put_to_platform tightening, not just wasm-sdk

The module-level doc and the put_to_platform doc both note the behavior change, but downstream Rust SDK consumers reading only the PR description/changelog will see only the wasm-sdk documentCreate/documentReplace note. PutDocument::put_to_platform is now stricter in two ways too: (1) revision = Some(0) is rejected by ensure_revision_for_replace (previously routed to the create branch because 0 > INITIAL_REVISION is false), and (2) when callers pass Some(entropy) on the create path, ensure_document_id_matches_entropy now rejects mismatches before nonce allocation. The only in-tree native caller (rs-platform-wallet/src/wallet/identity/network/profile.rs) uses revision: None on create and Some(current_revision + 1) on update so it's unaffected, but other downstream Rust SDK users could be surprised. Amend the PR description / release notes to call out the rs-sdk-side tightening alongside the wasm-sdk one.

source: ['claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 1-37: PR/release notes should call out the rs-sdk `PutDocument::put_to_platform` tightening, not just wasm-sdk
  The module-level doc and the `put_to_platform` doc both note the behavior change, but downstream Rust SDK consumers reading only the PR description/changelog will see only the wasm-sdk `documentCreate`/`documentReplace` note. `PutDocument::put_to_platform` is now stricter in two ways too: (1) `revision = Some(0)` is rejected by `ensure_revision_for_replace` (previously routed to the create branch because `0 > INITIAL_REVISION` is false), and (2) when callers pass `Some(entropy)` on the create path, `ensure_document_id_matches_entropy` now rejects mismatches before nonce allocation. The only in-tree native caller (`rs-platform-wallet/src/wallet/identity/network/profile.rs`) uses `revision: None` on create and `Some(current_revision + 1)` on update so it's unaffected, but other downstream Rust SDK users could be surprised. Amend the PR description / release notes to call out the rs-sdk-side tightening alongside the wasm-sdk one.

pub mod unshield;
pub mod update_price_of_document;
pub(crate) mod validation;
pub mod validation;
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: pub mod validation exports the whole module just to surface one helper

Changing pub(crate) mod validation to pub mod validation exposes every item in the module (including the private map_validation_result / is_unsupported_feature_error helpers and any future additions) just so wasm-sdk tests can import ensure_valid_state_transition_structure. Keep the module pub(crate) and add a targeted pub use platform::transition::validation::ensure_valid_state_transition_structure; re-export at the crate or pub mod transition level — that preserves freedom to reorganise the module's internals without a semver concern.

source: ['claude']

Comment on lines 551 to +909
}
}

// ============================================================================
// Prepare Document Create (Two-Phase API)
// ============================================================================

/// TypeScript interface for prepare document create options
#[wasm_bindgen(typescript_custom_section)]
const PREPARE_DOCUMENT_CREATE_OPTIONS_TS: &'static str = r#"
/**
* Options for preparing a document creation state transition without broadcasting.
*
* Use this for idempotent retry patterns:
* 1. Call `prepareDocumentCreate()` to get a signed `StateTransition`
* 2. Cache `stateTransition.toBytes()` for retry safety
* 3. Call `broadcastStateTransition(st)` + `waitForResponse(st)`
* 4. On timeout, deserialize cached bytes and rebroadcast the **identical** ST
*/
export interface PrepareDocumentCreateOptions {
/** The document to create. */
document: Document;
/** The identity public key to use for signing. */
identityKey: IdentityPublicKey;
/** Signer containing the private key for the identity key. */
signer: IdentitySigner;
/** Optional token payment agreement for document types with tokenCost.create. */
tokenPaymentInfo?: DocumentTokenPaymentInfo;
/** Optional settings (retries, timeouts, userFeeIncrease). */
settings?: PutSettings;
}
"#;

#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(typescript_type = "PrepareDocumentCreateOptions")]
pub type PrepareDocumentCreateOptionsJs;
}

#[wasm_bindgen]
impl WasmSdk {
/// Prepare a document creation state transition without broadcasting.
///
/// This method handles nonce management, ST construction, and signing, but does
/// **not** broadcast or wait for a response. The returned `StateTransition` can be:
///
/// - Serialized with `toBytes()` and cached for retry safety
/// - Broadcast with `broadcastStateTransition(st)`
/// - Awaited with `waitForResponse(st)`
///
/// This is the "prepare" half of the two-phase API. Use it when you need
/// idempotent retry behavior — on timeout, you can rebroadcast the exact same
/// signed transition instead of creating a new one with a new nonce.
///
/// **Nonce consumption:** A successful call advances this SDK instance's local
/// identity-contract nonce cache and embeds that nonce in the signed transition.
/// Platform state is not mutated until broadcast/processing. Only call this when
/// you intend to broadcast / persist-and-retry the returned transition. See module
/// docs for details.
///
/// @param options - Creation options including document, identity key, and signer
/// @returns The signed StateTransition ready for broadcasting
#[wasm_bindgen(js_name = "prepareDocumentCreate")]
pub async fn prepare_document_create(
&self,
options: PrepareDocumentCreateOptionsJs,
) -> Result<StateTransitionWasm, WasmSdkError> {
// Extract document from options
let document_wasm = DocumentWasm::try_from_options(&options, "document")?;
let document: Document = document_wasm.clone().into();

ensure_document_create_revision(document.revision(), "prepareDocumentReplace")?;

// Get metadata from document
let contract_id: Identifier = document_wasm.data_contract_id().into();
let document_type_name = document_wasm.document_type_name();

// Get entropy from document
let entropy = document_wasm.entropy().ok_or_else(|| {
WasmSdkError::invalid_argument("Document must have entropy set for creation")
})?;

if entropy.len() != 32 {
return Err(WasmSdkError::invalid_argument(
"Document entropy must be exactly 32 bytes",
));
}

let mut entropy_array = [0u8; 32];
entropy_array.copy_from_slice(&entropy);

// Extract identity key from options
let identity_key_wasm = IdentityPublicKeyWasm::try_from_options(&options, "identityKey")?;
let identity_key: IdentityPublicKey = identity_key_wasm.into();

// Extract signer from options
let signer = IdentitySignerWasm::try_from_options(&options, "signer")?;

// Fetch the data contract (using cache)
let data_contract = self.get_or_fetch_contract(contract_id).await?;

// Get document type (owned)
let document_type = get_document_type(&data_contract, &document_type_name)?;

// Extract settings from options
let settings =
try_from_options_optional::<PutSettingsInput>(&options, "settings")?.map(Into::into);
let token_payment_info = try_from_options_optional_token_payment_info(&options)?;

// Build, sign, and structurally validate the state transition without
// broadcasting it. Local pre-broadcast failures are rolled back inside
// rs-sdk so the identity-contract nonce cache cannot advance past a
// nonce the network never observed.
let state_transition = build_signed_document_create_transition(
self.inner_sdk(),
&document,
&document_type,
entropy_array,
&identity_key,
token_payment_info,
&signer,
settings,
)
.await?;

Ok(state_transition.into())
}
}

// ============================================================================
// Prepare Document Replace (Two-Phase API)
// ============================================================================

/// TypeScript interface for prepare document replace options
#[wasm_bindgen(typescript_custom_section)]
const PREPARE_DOCUMENT_REPLACE_OPTIONS_TS: &'static str = r#"
/**
* Options for preparing a document replace state transition without broadcasting.
*
* Use this for idempotent retry patterns. See `prepareDocumentCreate` for the full pattern.
*/
export interface PrepareDocumentReplaceOptions {
/** The document with updated data (same ID, incremented revision). */
document: Document;
/** The identity public key to use for signing. */
identityKey: IdentityPublicKey;
/** Signer containing the private key for the identity key. */
signer: IdentitySigner;
/** Optional token payment agreement for document types with tokenCost.replace. */
tokenPaymentInfo?: DocumentTokenPaymentInfo;
/** Optional settings (retries, timeouts, userFeeIncrease). */
settings?: PutSettings;
}
"#;

#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(typescript_type = "PrepareDocumentReplaceOptions")]
pub type PrepareDocumentReplaceOptionsJs;
}

#[wasm_bindgen]
impl WasmSdk {
/// Prepare a document replace state transition without broadcasting.
///
/// This method handles nonce management, ST construction, and signing, but does
/// **not** broadcast or wait for a response. See `prepareDocumentCreate` for
/// the full two-phase usage pattern.
///
/// **Nonce consumption:** A successful call advances this SDK instance's local
/// identity-contract nonce cache and embeds that nonce in the signed transition.
/// Platform state is not mutated until broadcast/processing. Only call this when
/// you intend to broadcast / persist-and-retry the returned transition. See module
/// docs for details.
///
/// @param options - Replace options including document, identity key, and signer
/// @returns The signed StateTransition ready for broadcasting
#[wasm_bindgen(js_name = "prepareDocumentReplace")]
pub async fn prepare_document_replace(
&self,
options: PrepareDocumentReplaceOptionsJs,
) -> Result<StateTransitionWasm, WasmSdkError> {
// Extract document from options
let document_wasm = DocumentWasm::try_from_options(&options, "document")?;
let document: Document = document_wasm.clone().into();

ensure_document_replace_revision(document.revision(), "prepareDocumentCreate")?;

// Get metadata from document
let contract_id: Identifier = document_wasm.data_contract_id().into();
let document_type_name = document_wasm.document_type_name();

// Extract identity key from options
let identity_key_wasm = IdentityPublicKeyWasm::try_from_options(&options, "identityKey")?;
let identity_key: IdentityPublicKey = identity_key_wasm.into();

// Extract signer from options
let signer = IdentitySignerWasm::try_from_options(&options, "signer")?;

// Fetch the data contract (using cache)
let data_contract = self.get_or_fetch_contract(contract_id).await?;

// Get document type (owned)
let document_type = get_document_type(&data_contract, &document_type_name)?;

// Extract settings from options
let settings =
try_from_options_optional::<PutSettingsInput>(&options, "settings")?.map(Into::into);
let token_payment_info = try_from_options_optional_token_payment_info(&options)?;

// Build, sign, and structurally validate the state transition without
// broadcasting it. Local pre-broadcast failures are rolled back inside
// rs-sdk so the identity-contract nonce cache cannot advance past a
// nonce the network never observed.
let state_transition = build_signed_document_replace_transition(
self.inner_sdk(),
&document,
&document_type,
&identity_key,
token_payment_info,
&signer,
settings,
)
.await?;

Ok(state_transition.into())
}
}

// ============================================================================
// Prepare Document Delete (Two-Phase API)
// ============================================================================

/// TypeScript interface for prepare document delete options
#[wasm_bindgen(typescript_custom_section)]
const PREPARE_DOCUMENT_DELETE_OPTIONS_TS: &'static str = r#"
/**
* Options for preparing a document delete state transition without broadcasting.
*
* Use this for idempotent retry patterns. See `prepareDocumentCreate` for the full pattern.
*/
export interface PrepareDocumentDeleteOptions {
/**
* The document to delete — either a Document instance or an object with identifiers.
*/
document: Document | {
id: IdentifierLike;
ownerId: IdentifierLike;
dataContractId: IdentifierLike;
documentTypeName: string;
};
/** The identity public key to use for signing. */
identityKey: IdentityPublicKey;
/** Signer containing the private key for the identity key. */
signer: IdentitySigner;
/** Optional token payment agreement for document types with tokenCost.delete. */
tokenPaymentInfo?: DocumentTokenPaymentInfo;
/** Optional settings (retries, timeouts, userFeeIncrease). */
settings?: PutSettings;
}
"#;

#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(typescript_type = "PrepareDocumentDeleteOptions")]
pub type PrepareDocumentDeleteOptionsJs;
}

#[wasm_bindgen]
impl WasmSdk {
/// Prepare a document delete state transition without broadcasting.
///
/// This method handles nonce management, ST construction, and signing, but does
/// **not** broadcast or wait for a response. See `prepareDocumentCreate` for
/// the full two-phase usage pattern.
///
/// **Nonce consumption:** A successful call advances this SDK instance's local
/// identity-contract nonce cache and embeds that nonce in the signed transition.
/// Platform state is not mutated until broadcast/processing. Only call this when
/// you intend to broadcast / persist-and-retry the returned transition. See module
/// docs for details.
///
/// @param options - Delete options including document identifiers, identity key, and signer
/// @returns The signed StateTransition ready for broadcasting
#[wasm_bindgen(js_name = "prepareDocumentDelete")]
pub async fn prepare_document_delete(
&self,
options: PrepareDocumentDeleteOptionsJs,
) -> Result<StateTransitionWasm, WasmSdkError> {
// Extract document field - can be either a Document instance or plain object
let document_js = js_sys::Reflect::get(&options, &JsValue::from_str("document"))
.map_err(|_| WasmSdkError::invalid_argument("document is required"))?;

if document_js.is_undefined() || document_js.is_null() {
return Err(WasmSdkError::invalid_argument("document is required"));
}

// Check if it's a Document instance or a plain object with fields
let (document_id, owner_id, contract_id, document_type_name): (
Identifier,
Identifier,
Identifier,
String,
) = if get_class_type(&document_js).ok().as_deref() == Some("Document") {
let doc: DocumentWasm = document_js
.to_wasm::<DocumentWasm>("Document")
.map(|boxed| (*boxed).clone())?;
let doc_inner: Document = doc.clone().into();
(
doc.id().into(),
doc_inner.owner_id(),
doc.data_contract_id().into(),
doc.document_type_name(),
)
} else {
(
IdentifierWasm::try_from_options(&document_js, "id")?.into(),
IdentifierWasm::try_from_options(&document_js, "ownerId")?.into(),
IdentifierWasm::try_from_options(&document_js, "dataContractId")?.into(),
try_from_options_with(&document_js, "documentTypeName", |v| {
try_to_string(v, "documentTypeName")
})?,
)
};

// Extract identity key from options
let identity_key_wasm = IdentityPublicKeyWasm::try_from_options(&options, "identityKey")?;
let identity_key: IdentityPublicKey = identity_key_wasm.into();

// Extract signer from options
let signer = IdentitySignerWasm::try_from_options(&options, "signer")?;

// Fetch the data contract (using cache)
let data_contract = self.get_or_fetch_contract(contract_id).await?;

// Extract settings from options
let settings =
try_from_options_optional::<PutSettingsInput>(&options, "settings")?.map(Into::into);
let token_payment_info = try_from_options_optional_token_payment_info(&options)?;

// Build the delete transition using the builder's sign method (which does NOT broadcast)
let builder = DocumentDeleteTransitionBuilder::new(
Arc::new(data_contract),
document_type_name,
document_id,
owner_id,
);

let builder = if let Some(token_payment_info) = token_payment_info {
builder.with_token_payment_info(token_payment_info)
} else {
builder
};

let builder = if let Some(s) = settings {
builder.with_settings(s)
} else {
builder
};
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Document-vs-plain-object extraction duplicated between document_delete and prepare_document_delete

The class-detection + field-extraction block in document_delete (~lines 481–509) is duplicated almost verbatim in prepare_document_delete (~lines 848–874), and both then construct the same DocumentDeleteTransitionBuilder (lines 526–544 vs 891–909). Extract a helper (e.g. extract_document_delete_inputs(document_js) -> Result<(Identifier, Identifier, Identifier, String), WasmSdkError>) plus a builder-construction helper to remove ~40 lines of duplication and keep the one-shot and prepare paths in lock-step if validation is tightened later.

source: ['claude']

Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

The PR is sound: strict create/replace helpers, shared delete helper, conditional pre-broadcast nonce rollback, and broader test coverage are all well-modeled. The most substantive remaining concern is consistency — the sibling DocumentCreateTransitionBuilder / DocumentReplaceTransitionBuilder paths in packages/rs-sdk/src/platform/documents/transitions/{create,replace}.rs still bump the identity-contract nonce without a rollback on local failure. Those files are unchanged in this PR (pre-existing condition), so this is a follow-up rather than a regression. All other findings are docs / test-coverage / minor architecture suggestions.

Reviewed commit: 3ba4b98

🟡 6 suggestion(s) | 💬 4 nitpick(s)

3 additional findings

🟡 suggestion: DocumentCreateTransitionBuilder::sign leaks the bumped identity-contract nonce on local failure

packages/rs-sdk/src/platform/documents/transitions/create.rs (lines 135-171)

DocumentCreateTransitionBuilder::sign() (called from Sdk::document_create) allocates the identity-contract nonce with bump_first = true, then awaits BatchTransition::new_document_creation_transition_from_document(...). Any error from that constructor (signer error, document_type lookup, build/validation) is returned directly with no Sdk::rollback_identity_contract_nonce call. The local cache is left at N+1 while the network still expects N, so the next create from the same Sdk instance must hit refresh_identity_nonce before it can succeed. The new put_document.rs legacy dispatcher and the shared delete helper already roll back identically positioned failures via Sdk::rollback_identity_contract_nonce, so leaving this builder out is an inconsistency, not a deliberate design choice. This file is not modified by this PR, so this is a pre-existing gap rather than a new regression, but it falls in scope of the PR's stated goal of tightening document-transition nonce handling and should be addressed in this PR or an immediate follow-up.

🟡 suggestion: DocumentReplaceTransitionBuilder::sign leaks the bumped identity-contract nonce on local failure

packages/rs-sdk/src/platform/documents/transitions/replace.rs (lines 130-165)

Same shape as the create builder: DocumentReplaceTransitionBuilder::sign() (called from Sdk::document_replace) bumps the identity-contract nonce, then awaits new_document_replacement_transition_from_document(...), and returns any error without rolling the nonce back. Locally-recoverable failures (signer errors, build/validation errors) leave the cache ahead of Platform; the next replace through this builder will sign with an off-by-one nonce until refresh_identity_nonce runs. The put_document legacy dispatcher already handles this for the trait-based path, so the builder API is the only document-transition entry point still missing the rollback. Like the create builder, this file is not modified by this PR — pre-existing gap, but in scope for the PR's nonce-handling theme.

💬 nitpick: Bincode encode failure after prepare leaves the bumped nonce un-rolled-back

packages/rs-sdk-ffi/src/document/delete.rs (lines 175-196)

The FFI delete prepare path rolls back the identity-contract nonce on every failure inside build_signed_document_delete_transition, but the subsequent bincode::encode_to_vec(&state_transition, config) returns a Result and a failure would leave the bumped nonce in the cache until the caller refreshes it. The inline comment explains this is effectively infallible for any transition the helper just returned, which is accurate — encoding an already-built valid StateTransition has no IO and no realistic failure mode. This is a defense-in-depth nit rather than a real bug; capture (owner_id, contract_id, allocated nonce) alongside the transition and roll back on encode error if you want symmetry with the helper's own rollback discipline.

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/documents/transitions/create.rs`:
- [SUGGESTION] lines 135-171: DocumentCreateTransitionBuilder::sign leaks the bumped identity-contract nonce on local failure
  `DocumentCreateTransitionBuilder::sign()` (called from `Sdk::document_create`) allocates the identity-contract nonce with `bump_first = true`, then awaits `BatchTransition::new_document_creation_transition_from_document(...)`. Any error from that constructor (signer error, document_type lookup, build/validation) is returned directly with no `Sdk::rollback_identity_contract_nonce` call. The local cache is left at `N+1` while the network still expects `N`, so the next create from the same Sdk instance must hit `refresh_identity_nonce` before it can succeed. The new `put_document.rs` legacy dispatcher and the shared delete helper already roll back identically positioned failures via `Sdk::rollback_identity_contract_nonce`, so leaving this builder out is an inconsistency, not a deliberate design choice. This file is not modified by this PR, so this is a pre-existing gap rather than a new regression, but it falls in scope of the PR's stated goal of tightening document-transition nonce handling and should be addressed in this PR or an immediate follow-up.

In `packages/rs-sdk/src/platform/documents/transitions/replace.rs`:
- [SUGGESTION] lines 130-165: DocumentReplaceTransitionBuilder::sign leaks the bumped identity-contract nonce on local failure
  Same shape as the create builder: `DocumentReplaceTransitionBuilder::sign()` (called from `Sdk::document_replace`) bumps the identity-contract nonce, then awaits `new_document_replacement_transition_from_document(...)`, and returns any error without rolling the nonce back. Locally-recoverable failures (signer errors, build/validation errors) leave the cache ahead of Platform; the next replace through this builder will sign with an off-by-one nonce until `refresh_identity_nonce` runs. The put_document legacy dispatcher already handles this for the trait-based path, so the builder API is the only document-transition entry point still missing the rollback. Like the create builder, this file is not modified by this PR — pre-existing gap, but in scope for the PR's nonce-handling theme.

In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 95-108: ensure_revision_for_replace error message is misleading for PutDocument::put_to_platform callers
  `put_to_platform` dispatches `revision == Some(0)` to the replace branch (since `revision().is_none() || revision() == Some(INITIAL_REVISION)` is false), which calls `build_signed_document_replace_transition` → `ensure_revision_for_replace`. That helper returns 'document revision is 0 but replace requires revision > 1; use the create path for new documents'. Callers of the unified `PutDocument::put_to_platform` trait don't see the internal replace routing — telling them to 'use the create path' is opaque and contradicts the routing rules in this same module. Either route `Some(0)` explicitly to `ensure_revision_nonzero` before dispatch, or reword the message to something true regardless of caller: 'revision 0 is invalid; use unset or 1 for create, or > 1 for replace'.
- [SUGGESTION] lines 392-413: Replace-side pre-broadcast rollback is not directly exercised by tests
  `build_signed_document_replace_transition` is a new public strict entry point with its own revision guard, but the test module only proves rollback behavior for the create helper. The replace branch of the revision-driven dispatcher and its rollback path are unexercised — a future change to `is_document_replace_revision`, the replace wrapper, or the shared legacy dispatch could break rollback or accidentally route replaces through the create branch while the existing create-focused tests still pass. Add a mock-based async test for a replace transition that fails after nonce allocation and asserts the next allocation reuses the rolled-back value.

In `packages/rs-sdk/src/platform/documents/transitions/delete.rs`:
- [SUGGESTION] lines 120-204: DocumentDeleteTransitionBuilder keeps user_fee_increase / state_transition_creation_options in two places
  Both `self.user_fee_increase` / `self.state_transition_creation_options` and `self.settings.{user_fee_increase, state_transition_creation_options}` must be kept in sync, and all three setters (`with_user_fee_increase`, `with_state_transition_creation_options`, `with_settings`) now carry reconciliation logic to preserve 'explicit setter wins'. Tests cover every call order today, but a future contributor extending `with_settings` or adding a third overlapping field is likely to miss the sync requirement. Consider storing these only on `self.settings` (or only on the dedicated fields) and computing the effective value at sign time so there is a single source of truth.

In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 613-922: New prepareDocument* WASM entry points have no wrapper-level tests
  The three `prepareDocument*` methods contain non-trivial wrapper logic — JS option extraction (`Reflect::get` + `unchecked_into`), entropy length validation, the `Document`-vs-plain-object split for delete, and conversion back into `StateTransitionWasm` — that is invisible to Rust's type system at the `JsValue` boundary. The current tests in this file cover only pure revision guards and an unrelated structure-validation helper, and `tests/unit/prepare-document.spec.ts` only validates argument-shape errors. Add at least one wrapper-level test per prepare method (mismatched entropy/id, bad entropy length, invalid options object) so regressions in field naming, branch selection, or error mapping fail loudly.

Comment on lines +95 to +108
fn ensure_revision_for_replace(revision: Option<u64>) -> Result<(), Error> {
match revision {
Some(rev) if rev > INITIAL_REVISION => Ok(()),
Some(rev) => Err(Error::InvalidArgument(format!(
"document revision is {rev} but replace requires revision > \
{INITIAL_REVISION}; use the create path for new documents"
))),
None => Err(Error::InvalidArgument(
"document must have a revision set for replace; \
use the create path for new documents"
.to_string(),
)),
}
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: ensure_revision_for_replace error message is misleading for PutDocument::put_to_platform callers

put_to_platform dispatches revision == Some(0) to the replace branch (since revision().is_none() || revision() == Some(INITIAL_REVISION) is false), which calls build_signed_document_replace_transitionensure_revision_for_replace. That helper returns 'document revision is 0 but replace requires revision > 1; use the create path for new documents'. Callers of the unified PutDocument::put_to_platform trait don't see the internal replace routing — telling them to 'use the create path' is opaque and contradicts the routing rules in this same module. Either route Some(0) explicitly to ensure_revision_nonzero before dispatch, or reword the message to something true regardless of caller: 'revision 0 is invalid; use unset or 1 for create, or > 1 for replace'.

source: ['claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 95-108: ensure_revision_for_replace error message is misleading for PutDocument::put_to_platform callers
  `put_to_platform` dispatches `revision == Some(0)` to the replace branch (since `revision().is_none() || revision() == Some(INITIAL_REVISION)` is false), which calls `build_signed_document_replace_transition` → `ensure_revision_for_replace`. That helper returns 'document revision is 0 but replace requires revision > 1; use the create path for new documents'. Callers of the unified `PutDocument::put_to_platform` trait don't see the internal replace routing — telling them to 'use the create path' is opaque and contradicts the routing rules in this same module. Either route `Some(0)` explicitly to `ensure_revision_nonzero` before dispatch, or reword the message to something true regardless of caller: 'revision 0 is invalid; use unset or 1 for create, or > 1 for replace'.

Comment on lines 120 to 204
pub fn with_user_fee_increase(mut self, user_fee_increase: UserFeeIncrease) -> Self {
self.user_fee_increase = Some(user_fee_increase);
if let Some(settings) = self.settings.as_mut() {
settings.user_fee_increase = Some(user_fee_increase);
}
self
}

/// Adds settings to the document delete transition
/// Adds settings to the document delete transition.
///
/// Explicit setters always win regardless of call order: if
/// [`Self::with_user_fee_increase`] or
/// [`Self::with_state_transition_creation_options`] has already been
/// called on this builder, the corresponding field in `settings` is
/// only used as a fallback when the dedicated builder field is still
/// `None`. This makes the builder order-independent for these two
/// fields and avoids silently clobbering a deliberate caller choice.
///
/// After this call, the stored [`PutSettings`] reflects the
/// **effective** values for `user_fee_increase` and
/// `state_transition_creation_options` — i.e. whichever value will
/// actually be applied at sign time — so `self.settings` and the
/// dedicated builder fields do not disagree. Other [`PutSettings`]
/// fields (timeouts, retry behavior, etc.) are preserved from the
/// supplied `settings`.
///
/// # Arguments
///
/// * `settings` - The settings to add
///
/// # Returns
///
/// * `Self` - The updated builder
pub fn with_settings(mut self, settings: PutSettings) -> Self {
self.settings = Some(settings);
if self.user_fee_increase.is_none() {
if let Some(user_fee_increase) = settings.user_fee_increase {
self.user_fee_increase = Some(user_fee_increase);
}
}
if self.state_transition_creation_options.is_none() {
if let Some(state_transition_creation_options) =
settings.state_transition_creation_options
{
self.state_transition_creation_options = Some(state_transition_creation_options);
}
}
// Merge effective values back into the stored settings so there is
// exactly one source of truth for `user_fee_increase` and
// `state_transition_creation_options`. Other PutSettings fields
// (timeouts, retry behavior, etc.) are preserved from the supplied
// `settings`.
let mut effective_settings = settings;
effective_settings.user_fee_increase = self.user_fee_increase;
effective_settings.state_transition_creation_options =
self.state_transition_creation_options;
self.settings = Some(effective_settings);
self
}

/// Adds creation_options to the document delete transition
/// Adds creation_options to the document delete transition.
///
/// Explicit setters always win regardless of call order. If
/// [`Self::with_settings`] has already stored a [`PutSettings`] on this
/// builder, its `state_transition_creation_options` is updated in
/// place so `self.settings` remains the single source of truth for
/// the effective value used at sign/broadcast time. Other
/// [`PutSettings`] fields are preserved.
///
/// # Arguments
///
/// * `creation_options` - The creation options to add
///
/// # Returns
///
/// * `Self` - The updated builder
pub fn with_state_transition_creation_options(
mut self,
creation_options: StateTransitionCreationOptions,
) -> Self {
self.state_transition_creation_options = Some(creation_options);
if let Some(settings) = self.settings.as_mut() {
settings.state_transition_creation_options = Some(creation_options);
}
self
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: DocumentDeleteTransitionBuilder keeps user_fee_increase / state_transition_creation_options in two places

Both self.user_fee_increase / self.state_transition_creation_options and self.settings.{user_fee_increase, state_transition_creation_options} must be kept in sync, and all three setters (with_user_fee_increase, with_state_transition_creation_options, with_settings) now carry reconciliation logic to preserve 'explicit setter wins'. Tests cover every call order today, but a future contributor extending with_settings or adding a third overlapping field is likely to miss the sync requirement. Consider storing these only on self.settings (or only on the dedicated fields) and computing the effective value at sign time so there is a single source of truth.

source: ['claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/documents/transitions/delete.rs`:
- [SUGGESTION] lines 120-204: DocumentDeleteTransitionBuilder keeps user_fee_increase / state_transition_creation_options in two places
  Both `self.user_fee_increase` / `self.state_transition_creation_options` and `self.settings.{user_fee_increase, state_transition_creation_options}` must be kept in sync, and all three setters (`with_user_fee_increase`, `with_state_transition_creation_options`, `with_settings`) now carry reconciliation logic to preserve 'explicit setter wins'. Tests cover every call order today, but a future contributor extending `with_settings` or adding a third overlapping field is likely to miss the sync requirement. Consider storing these only on `self.settings` (or only on the dedicated fields) and computing the effective value at sign time so there is a single source of truth.

Comment on lines +613 to +922
#[wasm_bindgen(js_name = "prepareDocumentCreate")]
pub async fn prepare_document_create(
&self,
options: PrepareDocumentCreateOptionsJs,
) -> Result<StateTransitionWasm, WasmSdkError> {
// Extract document from options
let document_wasm = DocumentWasm::try_from_options(&options, "document")?;
let document: Document = document_wasm.clone().into();

ensure_document_create_revision(document.revision(), "prepareDocumentReplace")?;

// Get metadata from document
let contract_id: Identifier = document_wasm.data_contract_id().into();
let document_type_name = document_wasm.document_type_name();

// Get entropy from document
let entropy = document_wasm.entropy().ok_or_else(|| {
WasmSdkError::invalid_argument("Document must have entropy set for creation")
})?;

if entropy.len() != 32 {
return Err(WasmSdkError::invalid_argument(
"Document entropy must be exactly 32 bytes",
));
}

let mut entropy_array = [0u8; 32];
entropy_array.copy_from_slice(&entropy);

// Extract identity key from options
let identity_key_wasm = IdentityPublicKeyWasm::try_from_options(&options, "identityKey")?;
let identity_key: IdentityPublicKey = identity_key_wasm.into();

// Extract signer from options
let signer = IdentitySignerWasm::try_from_options(&options, "signer")?;

// Fetch the data contract (using cache)
let data_contract = self.get_or_fetch_contract(contract_id).await?;

// Get document type (owned)
let document_type = get_document_type(&data_contract, &document_type_name)?;

// Extract settings from options
let settings =
try_from_options_optional::<PutSettingsInput>(&options, "settings")?.map(Into::into);
let token_payment_info = try_from_options_optional_token_payment_info(&options)?;

// Build, sign, and structurally validate the state transition without
// broadcasting it. Local pre-broadcast failures are rolled back inside
// rs-sdk so the identity-contract nonce cache cannot advance past a
// nonce the network never observed.
let state_transition = build_signed_document_create_transition(
self.inner_sdk(),
&document,
&document_type,
entropy_array,
&identity_key,
token_payment_info,
&signer,
settings,
)
.await?;

Ok(state_transition.into())
}
}

// ============================================================================
// Prepare Document Replace (Two-Phase API)
// ============================================================================

/// TypeScript interface for prepare document replace options
#[wasm_bindgen(typescript_custom_section)]
const PREPARE_DOCUMENT_REPLACE_OPTIONS_TS: &'static str = r#"
/**
* Options for preparing a document replace state transition without broadcasting.
*
* Use this for idempotent retry patterns. See `prepareDocumentCreate` for the full pattern.
*/
export interface PrepareDocumentReplaceOptions {
/** The document with updated data (same ID, incremented revision). */
document: Document;
/** The identity public key to use for signing. */
identityKey: IdentityPublicKey;
/** Signer containing the private key for the identity key. */
signer: IdentitySigner;
/** Optional token payment agreement for document types with tokenCost.replace. */
tokenPaymentInfo?: DocumentTokenPaymentInfo;
/** Optional settings (retries, timeouts, userFeeIncrease). */
settings?: PutSettings;
}
"#;

#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(typescript_type = "PrepareDocumentReplaceOptions")]
pub type PrepareDocumentReplaceOptionsJs;
}

#[wasm_bindgen]
impl WasmSdk {
/// Prepare a document replace state transition without broadcasting.
///
/// This method handles nonce management, ST construction, and signing, but does
/// **not** broadcast or wait for a response. See `prepareDocumentCreate` for
/// the full two-phase usage pattern.
///
/// **Nonce consumption:** A successful call advances this SDK instance's local
/// identity-contract nonce cache and embeds that nonce in the signed transition.
/// Platform state is not mutated until broadcast/processing. Only call this when
/// you intend to broadcast / persist-and-retry the returned transition. See module
/// docs for details.
///
/// @param options - Replace options including document, identity key, and signer
/// @returns The signed StateTransition ready for broadcasting
#[wasm_bindgen(js_name = "prepareDocumentReplace")]
pub async fn prepare_document_replace(
&self,
options: PrepareDocumentReplaceOptionsJs,
) -> Result<StateTransitionWasm, WasmSdkError> {
// Extract document from options
let document_wasm = DocumentWasm::try_from_options(&options, "document")?;
let document: Document = document_wasm.clone().into();

ensure_document_replace_revision(document.revision(), "prepareDocumentCreate")?;

// Get metadata from document
let contract_id: Identifier = document_wasm.data_contract_id().into();
let document_type_name = document_wasm.document_type_name();

// Extract identity key from options
let identity_key_wasm = IdentityPublicKeyWasm::try_from_options(&options, "identityKey")?;
let identity_key: IdentityPublicKey = identity_key_wasm.into();

// Extract signer from options
let signer = IdentitySignerWasm::try_from_options(&options, "signer")?;

// Fetch the data contract (using cache)
let data_contract = self.get_or_fetch_contract(contract_id).await?;

// Get document type (owned)
let document_type = get_document_type(&data_contract, &document_type_name)?;

// Extract settings from options
let settings =
try_from_options_optional::<PutSettingsInput>(&options, "settings")?.map(Into::into);
let token_payment_info = try_from_options_optional_token_payment_info(&options)?;

// Build, sign, and structurally validate the state transition without
// broadcasting it. Local pre-broadcast failures are rolled back inside
// rs-sdk so the identity-contract nonce cache cannot advance past a
// nonce the network never observed.
let state_transition = build_signed_document_replace_transition(
self.inner_sdk(),
&document,
&document_type,
&identity_key,
token_payment_info,
&signer,
settings,
)
.await?;

Ok(state_transition.into())
}
}

// ============================================================================
// Prepare Document Delete (Two-Phase API)
// ============================================================================

/// TypeScript interface for prepare document delete options
#[wasm_bindgen(typescript_custom_section)]
const PREPARE_DOCUMENT_DELETE_OPTIONS_TS: &'static str = r#"
/**
* Options for preparing a document delete state transition without broadcasting.
*
* Use this for idempotent retry patterns. See `prepareDocumentCreate` for the full pattern.
*/
export interface PrepareDocumentDeleteOptions {
/**
* The document to delete — either a Document instance or an object with identifiers.
*/
document: Document | {
id: IdentifierLike;
ownerId: IdentifierLike;
dataContractId: IdentifierLike;
documentTypeName: string;
};
/** The identity public key to use for signing. */
identityKey: IdentityPublicKey;
/** Signer containing the private key for the identity key. */
signer: IdentitySigner;
/** Optional token payment agreement for document types with tokenCost.delete. */
tokenPaymentInfo?: DocumentTokenPaymentInfo;
/** Optional settings (retries, timeouts, userFeeIncrease). */
settings?: PutSettings;
}
"#;

#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(typescript_type = "PrepareDocumentDeleteOptions")]
pub type PrepareDocumentDeleteOptionsJs;
}

#[wasm_bindgen]
impl WasmSdk {
/// Prepare a document delete state transition without broadcasting.
///
/// This method handles nonce management, ST construction, and signing, but does
/// **not** broadcast or wait for a response. See `prepareDocumentCreate` for
/// the full two-phase usage pattern.
///
/// **Nonce consumption:** A successful call advances this SDK instance's local
/// identity-contract nonce cache and embeds that nonce in the signed transition.
/// Platform state is not mutated until broadcast/processing. Only call this when
/// you intend to broadcast / persist-and-retry the returned transition. See module
/// docs for details.
///
/// @param options - Delete options including document identifiers, identity key, and signer
/// @returns The signed StateTransition ready for broadcasting
#[wasm_bindgen(js_name = "prepareDocumentDelete")]
pub async fn prepare_document_delete(
&self,
options: PrepareDocumentDeleteOptionsJs,
) -> Result<StateTransitionWasm, WasmSdkError> {
// Extract document field - can be either a Document instance or plain object
let document_js = js_sys::Reflect::get(&options, &JsValue::from_str("document"))
.map_err(|_| WasmSdkError::invalid_argument("document is required"))?;

if document_js.is_undefined() || document_js.is_null() {
return Err(WasmSdkError::invalid_argument("document is required"));
}

// Check if it's a Document instance or a plain object with fields
let (document_id, owner_id, contract_id, document_type_name): (
Identifier,
Identifier,
Identifier,
String,
) = if get_class_type(&document_js).ok().as_deref() == Some("Document") {
let doc: DocumentWasm = document_js
.to_wasm::<DocumentWasm>("Document")
.map(|boxed| (*boxed).clone())?;
let doc_inner: Document = doc.clone().into();
(
doc.id().into(),
doc_inner.owner_id(),
doc.data_contract_id().into(),
doc.document_type_name(),
)
} else {
(
IdentifierWasm::try_from_options(&document_js, "id")?.into(),
IdentifierWasm::try_from_options(&document_js, "ownerId")?.into(),
IdentifierWasm::try_from_options(&document_js, "dataContractId")?.into(),
try_from_options_with(&document_js, "documentTypeName", |v| {
try_to_string(v, "documentTypeName")
})?,
)
};

// Extract identity key from options
let identity_key_wasm = IdentityPublicKeyWasm::try_from_options(&options, "identityKey")?;
let identity_key: IdentityPublicKey = identity_key_wasm.into();

// Extract signer from options
let signer = IdentitySignerWasm::try_from_options(&options, "signer")?;

// Fetch the data contract (using cache)
let data_contract = self.get_or_fetch_contract(contract_id).await?;

// Extract settings from options
let settings =
try_from_options_optional::<PutSettingsInput>(&options, "settings")?.map(Into::into);
let token_payment_info = try_from_options_optional_token_payment_info(&options)?;

// Build the delete transition using the builder's sign method (which does NOT broadcast)
let builder = DocumentDeleteTransitionBuilder::new(
Arc::new(data_contract),
document_type_name,
document_id,
owner_id,
);

let builder = if let Some(token_payment_info) = token_payment_info {
builder.with_token_payment_info(token_payment_info)
} else {
builder
};

let builder = if let Some(s) = settings {
builder.with_settings(s)
} else {
builder
};

// Delegate the nonce-allocate / sign / structure-validate / rollback
// sequence to rs-sdk's shared helper so wasm-sdk and FFI share the
// single implementation.
let state_transition = build_signed_document_delete_transition(
self.inner_sdk(),
&builder,
&identity_key,
&signer,
)
.await?;

Ok(state_transition.into())
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: New prepareDocument WASM entry points have no wrapper-level tests*

The three prepareDocument* methods contain non-trivial wrapper logic — JS option extraction (Reflect::get + unchecked_into), entropy length validation, the Document-vs-plain-object split for delete, and conversion back into StateTransitionWasm — that is invisible to Rust's type system at the JsValue boundary. The current tests in this file cover only pure revision guards and an unrelated structure-validation helper, and tests/unit/prepare-document.spec.ts only validates argument-shape errors. Add at least one wrapper-level test per prepare method (mismatched entropy/id, bad entropy length, invalid options object) so regressions in field naming, branch selection, or error mapping fail loudly.

source: ['codex', 'claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 613-922: New prepareDocument* WASM entry points have no wrapper-level tests
  The three `prepareDocument*` methods contain non-trivial wrapper logic — JS option extraction (`Reflect::get` + `unchecked_into`), entropy length validation, the `Document`-vs-plain-object split for delete, and conversion back into `StateTransitionWasm` — that is invisible to Rust's type system at the `JsValue` boundary. The current tests in this file cover only pure revision guards and an unrelated structure-validation helper, and `tests/unit/prepare-document.spec.ts` only validates argument-shape errors. Add at least one wrapper-level test per prepare method (mismatched entropy/id, bad entropy length, invalid options object) so regressions in field naming, branch selection, or error mapping fail loudly.

Comment on lines +392 to +413
pub async fn build_signed_document_replace_transition<S: Signer<IdentityPublicKey>>(
sdk: &Sdk,
document: &Document,
document_type: &DocumentType,
identity_public_key: &IdentityPublicKey,
token_payment_info: Option<TokenPaymentInfo>,
signer: &S,
settings: Option<PutSettings>,
) -> Result<StateTransition, Error> {
ensure_revision_for_replace(document.revision())?;
build_signed_document_create_or_replace_transition_legacy(
sdk,
document,
document_type,
None, // entropy is unused on the replace path
identity_public_key,
token_payment_info,
signer,
settings,
)
.await
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Replace-side pre-broadcast rollback is not directly exercised by tests

build_signed_document_replace_transition is a new public strict entry point with its own revision guard, but the test module only proves rollback behavior for the create helper. The replace branch of the revision-driven dispatcher and its rollback path are unexercised — a future change to is_document_replace_revision, the replace wrapper, or the shared legacy dispatch could break rollback or accidentally route replaces through the create branch while the existing create-focused tests still pass. Add a mock-based async test for a replace transition that fails after nonce allocation and asserts the next allocation reuses the rolled-back value.

source: ['codex']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 392-413: Replace-side pre-broadcast rollback is not directly exercised by tests
  `build_signed_document_replace_transition` is a new public strict entry point with its own revision guard, but the test module only proves rollback behavior for the create helper. The replace branch of the revision-driven dispatcher and its rollback path are unexercised — a future change to `is_document_replace_revision`, the replace wrapper, or the shared legacy dispatch could break rollback or accidentally route replaces through the create branch while the existing create-focused tests still pass. Add a mock-based async test for a replace transition that fails after nonce allocation and asserts the next allocation reuses the rolled-back value.

Comment on lines +56 to +108
fn is_document_replace_revision(revision: Option<u64>) -> bool {
revision.is_some_and(|rev| rev > INITIAL_REVISION)
}

/// Reject documents whose revision is `Some(0)` for the create-or-replace
/// dispatch helper. Both create and replace require non-zero revisions, so
/// `0` is always invalid regardless of caller intent.
fn ensure_revision_nonzero(revision: Option<u64>) -> Result<(), Error> {
if matches!(revision, Some(0)) {
return Err(Error::InvalidArgument(
"document revision 0 is invalid; \
use unset or 1 (INITIAL_REVISION) for create, or > 1 for replace"
.to_string(),
));
}
Ok(())
}

/// Strict revision guard for the document **create** path.
///
/// Accepts `None` and `Some(INITIAL_REVISION)`. Rejects `Some(0)` and any
/// revision strictly greater than `INITIAL_REVISION`. This is the rs-sdk-side
/// fail-fast equivalent of the wasm-sdk `ensureDocumentCreateRevision` guard.
fn ensure_revision_for_create(revision: Option<u64>) -> Result<(), Error> {
match revision {
None => Ok(()),
Some(rev) if rev == INITIAL_REVISION => Ok(()),
Some(rev) => Err(Error::InvalidArgument(format!(
"document revision is {rev} but create requires revision \
to be unset or {INITIAL_REVISION}; use the replace path for revisions > {INITIAL_REVISION}"
))),
}
}

/// Strict revision guard for the document **replace** path.
///
/// Accepts only `Some(rev)` with `rev > INITIAL_REVISION`. Rejects `None`,
/// `Some(0)`, and `Some(INITIAL_REVISION)`. This is the rs-sdk-side fail-fast
/// equivalent of the wasm-sdk `ensureDocumentReplaceRevision` guard.
fn ensure_revision_for_replace(revision: Option<u64>) -> Result<(), Error> {
match revision {
Some(rev) if rev > INITIAL_REVISION => Ok(()),
Some(rev) => Err(Error::InvalidArgument(format!(
"document revision is {rev} but replace requires revision > \
{INITIAL_REVISION}; use the create path for new documents"
))),
None => Err(Error::InvalidArgument(
"document must have a revision set for replace; \
use the create path for new documents"
.to_string(),
)),
}
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Four overlapping revision predicates complicate the create/replace control flow

The module exposes four revision helpers — is_document_replace_revision, ensure_revision_nonzero, ensure_revision_for_create, ensure_revision_for_replace — and the strict wrappers run their own check, delegate to _legacy which runs ensure_revision_nonzero again, which routes through build_and_sign_create_or_replace_after_nonce that re-tests via is_document_replace_revision. The redundant checks are harmless (the stricter ones subsume the weaker), but a reader has to trace every branch to convince themselves Some(0) can never land in the create branch of the inner builder. Consider collapsing to a single RevisionIntent::{Create, Replace} enum produced at the entry point, or dropping ensure_revision_nonzero from _legacy now that both strict public wrappers cover its job.

source: ['claude']

Comment on lines +240 to +265
#[allow(clippy::too_many_arguments)]
#[deprecated(
note = "use build_signed_document_create_transition or build_signed_document_replace_transition for strict intent validation"
)]
pub async fn build_signed_document_create_or_replace_transition<S: Signer<IdentityPublicKey>>(
sdk: &Sdk,
document: &Document,
document_type: &DocumentType,
document_state_transition_entropy: Option<[u8; 32]>,
identity_public_key: &IdentityPublicKey,
token_payment_info: Option<TokenPaymentInfo>,
signer: &S,
settings: Option<PutSettings>,
) -> Result<StateTransition, Error> {
build_signed_document_create_or_replace_transition_legacy(
sdk,
document,
document_type,
document_state_transition_entropy,
identity_public_key,
token_payment_info,
signer,
settings,
)
.await
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Public deprecated dispatcher adds API surface with no documented in-tree caller

build_signed_document_create_or_replace_transition is introduced as a public function in this PR and immediately marked #[deprecated], with the actual logic in private _legacy. The doc says it's retained 'for source compatibility with downstream native callers that depended on it before the strict helpers were introduced', but git log shows this function name is new in this PR — there is no pre-existing public API to be source-compatible with. If no out-of-tree consumer already imports this exact name, drop the public wrapper and keep _legacy private. If a specific external consumer needs it (e.g. dash-evo-tool), name them in the doc comment so the deprecation has clear scope.

source: ['claude']

@@ -0,0 +1,347 @@
import { expect } from './helpers/chai.ts';
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: prepareDocumentCreate entropy/id mismatch not exercised through the wasm boundary

build_signed_document_create_transition has a Rust unit test verifying the id-matches-entropy guard fires before nonce allocation, but tests/unit/prepare-document.spec.ts only validates argument-shape errors and does not assert the entropy/id-mismatch path surfaced through the wasm wrapper. A small test that builds a Document with mismatched id and entropy, calls prepareDocumentCreate, and asserts the resulting WasmSdkError has kind InvalidArgument with a 'does not match' message would lock in the user-visible behavior across the wasm boundary.

source: ['claude']

Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

PR adds strict prepare/build helpers for document state transitions in rs-sdk and exposes prepare* APIs in wasm-sdk, with careful nonce allocate/rollback semantics. No blockers verified against the worktree. The main feedback areas: a brand-new public function shipped already #[deprecated], internal validation module visibility widened solely for a sibling-crate test, an undocumented behavior change in DocumentDeleteTransitionBuilder::with_settings, and the older Sdk::document_create/document_replace builder paths still bypass the new strict validation. Note: codex's blocking finding about Sdk::version() falling back to latest() (sdk.rs:1054-1059) was verified against the PR diff and is pre-existing code untouched by this PR — dropped as out of scope.

Reviewed commit: ff2285b

🟡 6 suggestion(s) | 💬 4 nitpick(s)

3 additional findings

🟡 suggestion: `Sdk::document_create` / `Sdk::document_replace` bypass the new strict pre-broadcast validation

packages/rs-sdk/src/platform/documents/transitions/create.rs (lines 250-271)

Both Sdk::document_create (packages/rs-sdk/src/platform/documents/transitions/create.rs:250-271) and Sdk::document_replace (packages/rs-sdk/src/platform/documents/transitions/replace.rs:261-270) sign via DocumentCreateTransitionBuilder::sign() / DocumentReplaceTransitionBuilder::sign() and broadcast immediately. Those builder paths never call ensure_valid_state_transition_structure and never reuse the new build_signed_document_create_transition / build_signed_document_replace_transition helpers. As a result, Rust callers using the builder API still get the old behavior: id/entropy mismatches and other locally-detectable structural problems are not rejected as InvalidArgument before broadcast, and the strict revision-vs-intent boundary is not enforced. The public rs-sdk now exposes two different reliability models for the same operation depending on which entry point the caller picks. Either route the builder path through the strict helpers, or document the divergence explicitly so callers know which API gives fail-fast structural validation.

🟡 suggestion: documentCreate now implicitly enforces id-matches-entropy at the JS/Rust boundary, but migration notes do not mention it

packages/wasm-sdk/src/state_transitions/document.rs (lines 217-278)

After this PR, documentCreate always forwards Some(entropy_array) into a path that calls ensure_document_id_matches_entropy and rejects with InvalidArgument when document.id != generate_document_id_v0(contract_id, owner_id, type_name, entropy). However, DocumentWasm::constructor (in wasm-dpp2) still permits JS callers to supply id and entropy independently — only when id is omitted does it derive the id from entropy. JS code that previously constructed a Document with an explicit id plus a non-matching entropy (which the old create path tolerated) will now fail at documentCreate/prepareDocumentCreate time. The PR description only calls out revision tightening for the JS-facing migration. Either (a) add a matching note to the wasm-sdk migration text and the DocumentCreateOptions / PrepareDocumentCreateOptions TS docstrings, or (b) validate the id-vs-entropy invariant in DocumentWasm::constructor so the failure surfaces at construction time.

🟡 suggestion: High-level `tokenPaymentInfo` parsing does not accept existing `TokenPaymentInfo` wasm class instances

packages/wasm-sdk/src/state_transitions/document.rs (lines 126-147)

try_from_options_optional_token_payment_info always rebuilds tokenPaymentInfo via TokenPaymentInfoWasm::constructor(...), which expects a plain TokenPaymentInfoOptions object and uses serde_wasm_bindgen::from_value(...) to deserialize primitive fields. Meanwhile the lower-level document transition bindings in wasm-dpp2 advertise tokenPaymentInfo?: TokenPaymentInfo (the class) — see packages/wasm-dpp2/src/state_transitions/batch/document_transitions/create.rs:24-28. The high-level wasm-sdk APIs do advertise a different TS type (DocumentTokenPaymentInfo, a plain options bag) so this is by design, but it's an interop inconsistency between the two JS-facing layers: a JS caller who reuses a new TokenPaymentInfo(...) instance with the high-level API will get a confusing InvalidArgument instead of the natural reuse path. Detect a wasm class instance and route it through TokenPaymentInfoWasm::try_from(&value) before falling back to the options-bag constructor.

💡 Suggested change
    let token_payment_info = if get_class_type(&token_payment_info_value)
        .ok()
        .as_deref()
        == Some("TokenPaymentInfo")
    {
        TokenPaymentInfoWasm::try_from(&token_payment_info_value)
    } else {
        TokenPaymentInfoWasm::constructor(
            token_payment_info_value.unchecked_into::<TokenPaymentInfoOptionsJs>(),
        )
    }
    .map_err(|err| WasmSdkError::invalid_argument(err.to_string()))?;
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/documents/transitions/create.rs`:
- [SUGGESTION] lines 250-271: `Sdk::document_create` / `Sdk::document_replace` bypass the new strict pre-broadcast validation
  Both `Sdk::document_create` (`packages/rs-sdk/src/platform/documents/transitions/create.rs:250-271`) and `Sdk::document_replace` (`packages/rs-sdk/src/platform/documents/transitions/replace.rs:261-270`) sign via `DocumentCreateTransitionBuilder::sign()` / `DocumentReplaceTransitionBuilder::sign()` and broadcast immediately. Those builder paths never call `ensure_valid_state_transition_structure` and never reuse the new `build_signed_document_create_transition` / `build_signed_document_replace_transition` helpers. As a result, Rust callers using the builder API still get the old behavior: id/entropy mismatches and other locally-detectable structural problems are not rejected as `InvalidArgument` before broadcast, and the strict revision-vs-intent boundary is not enforced. The public rs-sdk now exposes two different reliability models for the same operation depending on which entry point the caller picks. Either route the builder path through the strict helpers, or document the divergence explicitly so callers know which API gives fail-fast structural validation.

In `packages/rs-sdk/src/platform/documents/transitions/delete.rs`:
- [SUGGESTION] lines 146-160: `with_settings` now changes the effective `user_fee_increase` paid on the wire
  Before this PR, `DocumentDeleteTransitionBuilder::with_settings(settings)` just stored `settings` on `self.settings`; the sign path read `self.user_fee_increase.unwrap_or_default()` and `self.state_transition_creation_options` (both defaulting to `None`), so any values passed via `PutSettings` were silently dropped at sign time. With this PR, `with_settings` now extracts those two fields into the dedicated builder fields when they are still `None`. The fix is correct, but it changes the on-chain fee that some existing native callers pay (previously `0`/default, now whatever they intended to pass). The PR description only mentions the wasm-sdk revision routing compat note; this delete-side fee behavior change is not surfaced. Add a release-note entry so downstream rs-sdk consumers know that the `user_fee_increase` they pass via `with_settings(...).sign(...)` may now actually take effect.

In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 515-538: `put_to_platform` create path clones the document twice
  The rerouted `PutDocument::put_to_platform` create branch calls `resolve_document_create_entropy(self, &document_type, document_state_transition_entropy)` which clones `self` unconditionally. It then hands `&resolved_document` and `Some(resolved_entropy)` to `build_signed_document_create_transition`, which routes through `_legacy` → `build_and_sign_create_or_replace_after_nonce`, which calls `resolve_document_create_entropy(document, document_type, Some(entropy))` *again* — and because the entropy is now `Some`, that call also takes the `Some` branch and returns `(document.clone(), entropy)`. End result: two clones of the document per create call on the legacy trait path, where the previous inline implementation cloned at most once. `Document::properties` can be a sizeable `BTreeMap<String, Value>`, so this is an avoidable regression for callers still using the trait method (notably `rs-platform-wallet`). Simplest fix: skip the outer `resolve_document_create_entropy` and hand the document + original entropy directly to the strict create helper (or to `_legacy`).
- [SUGGESTION] lines 119-143: `ensure_document_id_matches_entropy` is hard-pinned to `generate_document_id_v0`
  The id-matches-entropy guard derives the expected id via `Document::generate_document_id_v0` only. This matches the current `resolve_document_create_entropy` helper, but document id derivation is otherwise versioned in dpp (`v0` suffix). If a future protocol version introduces a `generate_document_id_v1` (or a versioned dispatcher), this guard will silently reject documents whose ids are derived under the new scheme even though the transition build itself succeeds. Centralize the id-derivation behind a single versioned dispatch (or at least keep the two callsites adjacent, well-commented, and grep-able) so the next migration updates both at once.

In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 217-278: documentCreate now implicitly enforces id-matches-entropy at the JS/Rust boundary, but migration notes do not mention it
  After this PR, `documentCreate` always forwards `Some(entropy_array)` into a path that calls `ensure_document_id_matches_entropy` and rejects with `InvalidArgument` when `document.id != generate_document_id_v0(contract_id, owner_id, type_name, entropy)`. However, `DocumentWasm::constructor` (in `wasm-dpp2`) still permits JS callers to supply `id` and `entropy` independently — only when `id` is omitted does it derive the id from entropy. JS code that previously constructed a `Document` with an explicit `id` plus a non-matching `entropy` (which the old create path tolerated) will now fail at `documentCreate`/`prepareDocumentCreate` time. The PR description only calls out revision tightening for the JS-facing migration. Either (a) add a matching note to the wasm-sdk migration text and the `DocumentCreateOptions` / `PrepareDocumentCreateOptions` TS docstrings, or (b) validate the id-vs-entropy invariant in `DocumentWasm::constructor` so the failure surfaces at construction time.
- [SUGGESTION] lines 126-147: High-level `tokenPaymentInfo` parsing does not accept existing `TokenPaymentInfo` wasm class instances
  `try_from_options_optional_token_payment_info` always rebuilds `tokenPaymentInfo` via `TokenPaymentInfoWasm::constructor(...)`, which expects a plain `TokenPaymentInfoOptions` object and uses `serde_wasm_bindgen::from_value(...)` to deserialize primitive fields. Meanwhile the lower-level document transition bindings in wasm-dpp2 advertise `tokenPaymentInfo?: TokenPaymentInfo` (the class) — see `packages/wasm-dpp2/src/state_transitions/batch/document_transitions/create.rs:24-28`. The high-level wasm-sdk APIs do advertise a different TS type (`DocumentTokenPaymentInfo`, a plain options bag) so this is by design, but it's an interop inconsistency between the two JS-facing layers: a JS caller who reuses a `new TokenPaymentInfo(...)` instance with the high-level API will get a confusing `InvalidArgument` instead of the natural reuse path. Detect a wasm class instance and route it through `TokenPaymentInfoWasm::try_from(&value)` before falling back to the options-bag constructor.

Comment on lines +240 to +265
#[allow(clippy::too_many_arguments)]
#[deprecated(
note = "use build_signed_document_create_transition or build_signed_document_replace_transition for strict intent validation"
)]
pub async fn build_signed_document_create_or_replace_transition<S: Signer<IdentityPublicKey>>(
sdk: &Sdk,
document: &Document,
document_type: &DocumentType,
document_state_transition_entropy: Option<[u8; 32]>,
identity_public_key: &IdentityPublicKey,
token_payment_info: Option<TokenPaymentInfo>,
signer: &S,
settings: Option<PutSettings>,
) -> Result<StateTransition, Error> {
build_signed_document_create_or_replace_transition_legacy(
sdk,
document,
document_type,
document_state_transition_entropy,
identity_public_key,
token_payment_info,
signer,
settings,
)
.await
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Brand-new pub function shipped already #[deprecated]

build_signed_document_create_or_replace_transition is introduced in this PR and is simultaneously annotated #[deprecated]. The doc comment justifies it as preserving "source compatibility with downstream native callers that depended on it before the strict helpers were introduced," but there are no such pre-existing callers — the function did not exist before this PR, and in-crate code routes through the private _legacy variant to avoid the deprecation warning. Shipping a public function in a deprecated state on its first release emits a deprecation warning on any first-time consumer for no observable migration benefit. Either drop the #[deprecated] attribute (and treat it purely as the legacy dispatch entry point) or remove the public function entirely and have callers compose the two strict helpers themselves.

source: ['claude', 'codex']

pub mod unshield;
pub mod update_price_of_document;
pub(crate) mod validation;
pub mod validation;
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: validation module visibility widened from pub(crate) to pub solely for a wasm-sdk test

The only consumer that requires the wider visibility is packages/wasm-sdk/src/state_transitions/document.rs:1353, which imports ensure_valid_state_transition_structure inside a #[cfg(test)] regression test. Promoting an implementation-detail helper to a permanent public-API guarantee for a downstream test widens the API surface for the wrong reason: any future change to that helper's signature is now semver-affecting. Prefer either (a) moving the regression test into rs-sdk where the helper can stay pub(crate), or (b) re-exposing the helper via a #[cfg(any(test, feature = "test-utils"))]-gated module so the relaxation is opt-in for test configurations only.

source: ['claude', 'codex']

Comment on lines 146 to 160
pub fn with_settings(mut self, mut settings: PutSettings) -> Self {
if self.user_fee_increase.is_none() {
self.user_fee_increase = settings.user_fee_increase;
}
if self.state_transition_creation_options.is_none() {
self.state_transition_creation_options = settings.state_transition_creation_options;
}
// Strip the fee/creation-options fields from the stored settings so
// the dedicated builder fields are the sole source of truth at
// sign time. The remainder of `settings` flows through unchanged.
settings.user_fee_increase = None;
settings.state_transition_creation_options = None;
self.settings = Some(settings);
self
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: with_settings now changes the effective user_fee_increase paid on the wire

Before this PR, DocumentDeleteTransitionBuilder::with_settings(settings) just stored settings on self.settings; the sign path read self.user_fee_increase.unwrap_or_default() and self.state_transition_creation_options (both defaulting to None), so any values passed via PutSettings were silently dropped at sign time. With this PR, with_settings now extracts those two fields into the dedicated builder fields when they are still None. The fix is correct, but it changes the on-chain fee that some existing native callers pay (previously 0/default, now whatever they intended to pass). The PR description only mentions the wasm-sdk revision routing compat note; this delete-side fee behavior change is not surfaced. Add a release-note entry so downstream rs-sdk consumers know that the user_fee_increase they pass via with_settings(...).sign(...) may now actually take effect.

source: ['claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/documents/transitions/delete.rs`:
- [SUGGESTION] lines 146-160: `with_settings` now changes the effective `user_fee_increase` paid on the wire
  Before this PR, `DocumentDeleteTransitionBuilder::with_settings(settings)` just stored `settings` on `self.settings`; the sign path read `self.user_fee_increase.unwrap_or_default()` and `self.state_transition_creation_options` (both defaulting to `None`), so any values passed via `PutSettings` were silently dropped at sign time. With this PR, `with_settings` now extracts those two fields into the dedicated builder fields when they are still `None`. The fix is correct, but it changes the on-chain fee that some existing native callers pay (previously `0`/default, now whatever they intended to pass). The PR description only mentions the wasm-sdk revision routing compat note; this delete-side fee behavior change is not surfaced. Add a release-note entry so downstream rs-sdk consumers know that the `user_fee_increase` they pass via `with_settings(...).sign(...)` may now actually take effect.

Comment on lines +285 to +318
ensure_revision_nonzero(document.revision())?;

let owner_id = document.owner_id();
let contract_id = document_type.data_contract_id();
let new_identity_contract_nonce = sdk
.get_identity_contract_nonce(owner_id, contract_id, true, settings)
.await?;

let result = build_and_sign_create_or_replace_after_nonce(
sdk,
document,
document_type,
document_state_transition_entropy,
identity_public_key,
token_payment_info,
signer,
settings,
new_identity_contract_nonce,
)
.await;

match result {
Ok(transition) => Ok(transition),
Err(err) => {
sdk.rollback_identity_contract_nonce(
owner_id,
contract_id,
new_identity_contract_nonce,
)
.await;
Err(err)
}
}
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Revision is validated twice on the strict create/replace paths

On the strict create path, ensure_revision_for_create rejects anything other than None/Some(INITIAL_REVISION), and then the legacy dispatcher it forwards into runs ensure_revision_nonzero(document.revision()) again before allocating the nonce. Same for replace. put_to_platform then adds a third ensure_revision_nonzero call before branching into the strict helpers. The duplication is harmless but is dead defensive code that future maintainers must convince themselves are not subtly different guards. Consolidate the dispatch-level guard into the strict helpers (or skip the legacy dispatcher's pre-check when called from a strict helper) so there is one revision-guard codepath per intent.

source: ['claude', 'codex']

Comment on lines 515 to 538
let transition = if self.revision().is_none() || self.revision() == Some(INITIAL_REVISION) {
// Create path. Preserve legacy behavior: when the caller did not
// supply entropy, generate it and rewrite `document.id` so the
// pair stays consistent before we hand the (document, entropy)
// to the strict create helper. The strict helper still verifies
// that `document.id == generate_document_id_v0(entropy)` before
// allocating any nonce, so the legacy fallback cannot mask an
// id/entropy mismatch.
let (resolved_document, resolved_entropy) = resolve_document_create_entropy(
self,
&document_type,
document_state_transition_entropy,
);
build_signed_document_create_transition(
sdk,
&resolved_document,
&document_type,
resolved_entropy,
&identity_public_key,
new_identity_contract_nonce,
settings.user_fee_increase.unwrap_or_default(),
token_payment_info,
signer,
sdk.version(),
settings.state_transition_creation_options,
settings,
)
.await?
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: put_to_platform create path clones the document twice

The rerouted PutDocument::put_to_platform create branch calls resolve_document_create_entropy(self, &document_type, document_state_transition_entropy) which clones self unconditionally. It then hands &resolved_document and Some(resolved_entropy) to build_signed_document_create_transition, which routes through _legacybuild_and_sign_create_or_replace_after_nonce, which calls resolve_document_create_entropy(document, document_type, Some(entropy)) again — and because the entropy is now Some, that call also takes the Some branch and returns (document.clone(), entropy). End result: two clones of the document per create call on the legacy trait path, where the previous inline implementation cloned at most once. Document::properties can be a sizeable BTreeMap<String, Value>, so this is an avoidable regression for callers still using the trait method (notably rs-platform-wallet). Simplest fix: skip the outer resolve_document_create_entropy and hand the document + original entropy directly to the strict create helper (or to _legacy).

source: ['claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 515-538: `put_to_platform` create path clones the document twice
  The rerouted `PutDocument::put_to_platform` create branch calls `resolve_document_create_entropy(self, &document_type, document_state_transition_entropy)` which clones `self` unconditionally. It then hands `&resolved_document` and `Some(resolved_entropy)` to `build_signed_document_create_transition`, which routes through `_legacy` → `build_and_sign_create_or_replace_after_nonce`, which calls `resolve_document_create_entropy(document, document_type, Some(entropy))` *again* — and because the entropy is now `Some`, that call also takes the `Some` branch and returns `(document.clone(), entropy)`. End result: two clones of the document per create call on the legacy trait path, where the previous inline implementation cloned at most once. `Document::properties` can be a sizeable `BTreeMap<String, Value>`, so this is an avoidable regression for callers still using the trait method (notably `rs-platform-wallet`). Simplest fix: skip the outer `resolve_document_create_entropy` and hand the document + original entropy directly to the strict create helper (or to `_legacy`).

Comment on lines +1295 to +1330
fn ensure_document_create_revision(
revision: Option<u64>,
replace_api_name: &str,
) -> Result<(), WasmSdkError> {
if let Some(revision) = revision {
if revision != INITIAL_REVISION {
return Err(WasmSdkError::invalid_argument(format!(
"Document revision is {} but create requires revision to be unset or {}. Use {} for existing documents.",
revision, INITIAL_REVISION, replace_api_name,
)));
}
}

Ok(())
}

fn ensure_document_replace_revision(
revision: Option<u64>,
create_api_name: &str,
) -> Result<(), WasmSdkError> {
let revision = revision.ok_or_else(|| {
WasmSdkError::invalid_argument(format!(
"Document must have a revision set for replace. Use {} for new documents.",
create_api_name,
))
})?;

if revision <= INITIAL_REVISION {
return Err(WasmSdkError::invalid_argument(format!(
"Document revision is {} but replace requires revision > {}. Use {} for new documents.",
revision, INITIAL_REVISION, create_api_name,
)));
}

Ok(())
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Revision-0 error message points callers to the opposite API, which also rejects 0

ensure_document_create_revision(Some(0), "documentReplace") produces "Document revision is 0 but create requires revision to be unset or 1. Use documentReplace for existing documents.", but documentReplace / prepareDocumentReplace also reject revision 0 (via ensure_document_replace_revision, which directs the caller back to documentCreate). A user who follows either suggestion bounces between the two APIs. The strict rs-sdk-side helpers in put_document.rs use clearer wording. Unify the wasm-sdk helpers so revision 0 is rejected as "revision 0 is invalid; create requires unset or 1, replace requires > 1" rather than redirecting between sibling APIs that both reject it.

source: ['claude']

Comment on lines +119 to +143
fn ensure_document_id_matches_entropy(
document: &Document,
document_type: &DocumentType,
entropy: &[u8; 32],
) -> Result<(), Error> {
let expected = Document::generate_document_id_v0(
&document_type.data_contract_id(),
&document.owner_id(),
document_type.name(),
entropy.as_slice(),
);
if document.id() != expected {
return Err(Error::InvalidArgument(format!(
"document.id does not match \
generate_document_id_v0(contract_id, owner_id, document_type_name, entropy); \
expected {expected}, got {got}. \
Either set document.id to the derived value before calling \
build_signed_document_create_transition, or use the legacy \
PutDocument::put_to_platform trait method which auto-generates \
entropy and rewrites the document id when entropy is None.",
got = document.id()
)));
}
Ok(())
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: ensure_document_id_matches_entropy is hard-pinned to generate_document_id_v0

The id-matches-entropy guard derives the expected id via Document::generate_document_id_v0 only. This matches the current resolve_document_create_entropy helper, but document id derivation is otherwise versioned in dpp (v0 suffix). If a future protocol version introduces a generate_document_id_v1 (or a versioned dispatcher), this guard will silently reject documents whose ids are derived under the new scheme even though the transition build itself succeeds. Centralize the id-derivation behind a single versioned dispatch (or at least keep the two callsites adjacent, well-commented, and grep-able) so the next migration updates both at once.

source: ['claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 119-143: `ensure_document_id_matches_entropy` is hard-pinned to `generate_document_id_v0`
  The id-matches-entropy guard derives the expected id via `Document::generate_document_id_v0` only. This matches the current `resolve_document_create_entropy` helper, but document id derivation is otherwise versioned in dpp (`v0` suffix). If a future protocol version introduces a `generate_document_id_v1` (or a versioned dispatcher), this guard will silently reject documents whose ids are derived under the new scheme even though the transition build itself succeeds. Centralize the id-derivation behind a single versioned dispatch (or at least keep the two callsites adjacent, well-commented, and grep-able) so the next migration updates both at once.

Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Two-phase prepare/broadcast document API in wasm-sdk plus shared pre-broadcast core in rs-sdk with nonce rollback is well-designed and well-tested. Verification confirms two real asymmetries between the create/replace builders and Sdk::document_create/document_replace one-shot paths: builder.sign() drops user_fee_increase/state_transition_creation_options set via with_settings, while the one-shot path overlays them. Several additional consistency, validation-ordering, and error-message issues exist. 4 lower-confidence findings dropped to fit budget.

Reviewed commit: 214dde3

🔴 2 blocking | 🟡 7 suggestion(s) | 💬 1 nitpick(s)

1 additional finding

🟡 suggestion: DocumentDeleteTransitionBuilder::sign_with_nonce skips ensure_valid_state_transition_structure (asymmetric with create/replace)

packages/rs-sdk/src/platform/documents/transitions/delete.rs (lines 237-281)

DocumentCreateTransitionBuilder::sign_with_nonce (create.rs:241) and DocumentReplaceTransitionBuilder::sign_with_nonce (replace.rs:218) both end with ensure_valid_state_transition_structure(&state_transition, platform_version)? before returning. DocumentDeleteTransitionBuilder::sign_with_nonce (delete.rs:237-281) does not — structure validation for delete is only run inside build_signed_document_delete_transition. Native callers using the builder's sign_with_nonce directly therefore get different validation behavior depending on which kind of transition they build, which is surprising. Either move the validation into sign_with_nonce (consistent with create/replace) or document the asymmetry explicitly in the delete builder's doc comment.

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/documents/transitions/create.rs`:
- [BLOCKING] lines 227-244: DocumentCreateTransitionBuilder::sign_with_nonce silently drops user_fee_increase / creation_options set via with_settings
  `DocumentCreateTransitionBuilder::with_settings` (create.rs:107-110) stores the incoming `PutSettings` whole into `self.settings`, but `sign_with_nonce` (create.rs:227-244) builds the transition off of `self.user_fee_increase.unwrap_or_default()` and `self.state_transition_creation_options` only — it never falls back to the values in `self.settings`. This PR teaches `Sdk::document_create` to fix that by destructuring the builder, overlaying `user_fee_increase` / `state_transition_creation_options` onto `effective_settings`, and passing it to `build_signed_document_create_transition` (create.rs:286-332). The result is an asymmetric foot-gun: a native two-phase caller who does `builder.with_settings(PutSettings { user_fee_increase: Some(42), state_transition_creation_options: Some(opts), .. }).sign(sdk, ...)` silently signs a transition with `user_fee_increase = 0` and no creation options, while the same builder handed to `sdk.document_create(builder, ...)` gets the requested 42 and opts. Because `user_fee_increase` is part of the signed transition, this is a correctness gap, not cosmetics. The same fix applied at the one-shot call site should live inside the builder (or `with_settings` should extract the two fields up-front the way `DocumentDeleteTransitionBuilder::with_settings` (delete.rs:146-160) already does).

In `packages/rs-sdk/src/platform/documents/transitions/replace.rs`:
- [BLOCKING] lines 191-221: DocumentReplaceTransitionBuilder::sign_with_nonce silently drops user_fee_increase / creation_options set via with_settings
  Same root cause as the create builder. `with_settings` (replace.rs:101-104) stores settings whole, but `sign_with_nonce` (replace.rs:191-221) reads only `self.user_fee_increase` and `self.state_transition_creation_options`. Meanwhile this PR added a `Sdk::document_replace` overlay (replace.rs:284-312) that merges the builder fields into `effective_settings` before signing. So a caller using `.with_settings(PutSettings{ user_fee_increase: Some(N), .. }).sign(...)` produces a different signed wire transition than `.with_settings(...).then(sdk.document_replace(builder))`. Because the fee increase is bound into the signed transition this can silently understate the fee bump and drop creation options on the prepare-style API. Fix by extracting the two fields inside `with_settings` (mirroring delete.rs:146-160) or by overlaying from `self.settings` inside `sign_with_nonce`.

In `packages/rs-sdk/src/platform/documents/transitions/delete.rs`:
- [SUGGESTION] lines 202-225: DocumentDeleteTransitionBuilder::sign leaks the allocated nonce on signing failure
  `DocumentCreateTransitionBuilder::sign` (create.rs:176-195) and `DocumentReplaceTransitionBuilder::sign` (replace.rs:159-178) both wrap the `sign_with_nonce` call in a `match` and call `sdk.rollback_identity_contract_nonce(...)` on the error branch. `DocumentDeleteTransitionBuilder::sign` (delete.rs:202-225) only uses `?` — a signing failure (e.g. signer error, document_type lookup failure inside `sign_with_nonce`) silently advances this SDK instance's nonce cache past a nonce the network never observes. The wasm-sdk `prepareDocumentDelete`/`document_delete` paths are protected because they route through `build_signed_document_delete_transition` which handles rollback, and the docstring at lines 184-189 acknowledges the gap and points users at `sign_with_nonce` for rollback control. But the builder shape matches create/replace exactly, so direct native callers reasonably expect symmetric behavior. Align `sign` with create/replace.
- [SUGGESTION] lines 237-281: DocumentDeleteTransitionBuilder::sign_with_nonce skips ensure_valid_state_transition_structure (asymmetric with create/replace)
  `DocumentCreateTransitionBuilder::sign_with_nonce` (create.rs:241) and `DocumentReplaceTransitionBuilder::sign_with_nonce` (replace.rs:218) both end with `ensure_valid_state_transition_structure(&state_transition, platform_version)?` before returning. `DocumentDeleteTransitionBuilder::sign_with_nonce` (delete.rs:237-281) does not — structure validation for delete is only run inside `build_signed_document_delete_transition`. Native callers using the builder's `sign_with_nonce` directly therefore get different validation behavior depending on which kind of transition they build, which is surprising. Either move the validation into `sign_with_nonce` (consistent with create/replace) or document the asymmetry explicitly in the delete builder's doc comment.

In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 1341-1376: WASM revision guards point users at sibling API that also rejects revision=0
  `ensure_document_create_revision` collapses any non-`INITIAL_REVISION` value (including `Some(0)`) into "Use {replace_api_name} for existing documents", and `ensure_document_replace_revision` likewise tells callers of revision=0 to "Use {create_api_name} for new documents". After this PR, revision=0 is rejected on both paths (the rs-sdk side special-cases this via `ensure_revision_nonzero`), so the guidance is contradictory. JS callers who hit either error and follow the message will hit the same `InvalidArgument` on the other API. Add a dedicated arm for `Some(0)` that says revision 0 is invalid for both paths.
- [SUGGESTION] lines 660-720: prepareDocumentCreate performs network-backed contract fetch before rejecting invalid id/entropy
  The new strict create-path invariant (`ensure_document_id_matches_entropy`) is supposed to make obviously invalid input fail fast. But `prepareDocumentCreate` awaits `self.get_or_fetch_contract(contract_id)` at line 696 before reaching `build_signed_document_create_transition`, where the id-vs-entropy check actually happens (after revision/id-match validation in `build_signed_document_create_transition_owned`). A caller with a malformed document still pays for network I/O and can see a transport error instead of `InvalidArgument`, despite the helper having all data needed to derive the expected id locally (`dataContractId`, `ownerId`, `documentTypeName`, `entropy` are all extracted before the fetch). The same ordering exists in `document_create()`. This weakens the new two-phase API for offline/retry-heavy clients.
- [SUGGESTION] lines 142-176: try_from_options_optional_token_payment_info decides ABI parsing path from attacker-controlled __type string
  `get_class_type` (line 164) reads the `__type` getter set up by `impl_wasm_type_info!` to decide between two branches: if `__type == "TokenPaymentInfo"`, the helper invokes `TokenPaymentInfoWasm::try_from(&value)`, which routes through `IntoWasm` and reads the `__wbg_ptr` integer off the JS object before calling `ref_from_abi`. JS-side code (untrusted page or third-party package) can hand the WASM SDK a forged object like `{ __type: "TokenPaymentInfo", __wbg_ptr: 123456 }` and force Rust to reinterpret an arbitrary integer as a `TokenPaymentInfoWasm` pointer inside the wasm linear-memory heap. The wasm sandbox prevents host-memory escape, but the type confusion can still cause wasm traps or read structurally-valid garbage as the payment info bound into the signed transition — a defensive boundary worth tightening. Two separate concerns: (1) gate the wrapper branch on a stronger identity check than a JS-readable getter, and (2) the `else` branch currently calls `unchecked_into::<TokenPaymentInfoOptionsJs>` on any non-`"TokenPaymentInfo"` class type (e.g. a related wasm-dpp2 wrapper), which will produce a hard-to-diagnose error or partially-decoded payment info — gate the constructor branch on `class_type.is_empty()` (plain object) and reject other class types explicitly.

In `packages/rs-sdk/src/platform/transition/validation.rs`:
- [SUGGESTION] lines 24-40: map_validation_result silently drops UnsupportedFeatureError entries from mixed error lists — call out in PR notes
  Previously `ensure_valid_state_transition_structure` returned `Ok` only when *every* error was `UnsupportedFeatureError`, otherwise it surfaced the full validation result. The new `map_validation_result` filters `UnsupportedFeatureError` entries out and returns only the remaining errors (validation.rs:29-39). The new behavior is sensible (no "not implemented" noise mixed with real errors) and the test at lines 100-111 covers it. But callers that depended on `Result.errors` faithfully reflecting what DPP returned — error counts, ordered iteration, diagnostic logging — will see different shapes. This is a public behavior change beyond document transitions; mention it in the PR description's compatibility note.

In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 511-532: Strict replace helper re-routes through the deprecated legacy dispatcher
  `build_signed_document_replace_transition` validates `revision > INITIAL_REVISION` and then calls `build_signed_document_create_or_replace_transition_legacy(..., None, ...)`. That legacy dispatcher re-runs `ensure_revision_nonzero` and then dispatches into `build_and_sign_create_or_replace_after_nonce`, which carries the unreachable create branch (entropy resolution, doc id rewrite) for the sole purpose of choosing the replace path on revisions > INITIAL_REVISION. For symmetry with `build_signed_document_create_transition` / `build_signed_document_create_transition_owned`, add a dedicated `build_signed_document_replace_transition_inner` (validate → allocate nonce → `new_document_replacement_transition_from_document` → validate structure → rollback on err). As written, the strict replace path keeps an architectural dependency on the dispatcher it supersedes.

Comment on lines 227 to 244
@@ -167,6 +238,8 @@ impl DocumentCreateTransitionBuilder {
)
.await?;

ensure_valid_state_transition_structure(&state_transition, platform_version)?;

Ok(state_transition)
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: DocumentCreateTransitionBuilder::sign_with_nonce silently drops user_fee_increase / creation_options set via with_settings

DocumentCreateTransitionBuilder::with_settings (create.rs:107-110) stores the incoming PutSettings whole into self.settings, but sign_with_nonce (create.rs:227-244) builds the transition off of self.user_fee_increase.unwrap_or_default() and self.state_transition_creation_options only — it never falls back to the values in self.settings. This PR teaches Sdk::document_create to fix that by destructuring the builder, overlaying user_fee_increase / state_transition_creation_options onto effective_settings, and passing it to build_signed_document_create_transition (create.rs:286-332). The result is an asymmetric foot-gun: a native two-phase caller who does builder.with_settings(PutSettings { user_fee_increase: Some(42), state_transition_creation_options: Some(opts), .. }).sign(sdk, ...) silently signs a transition with user_fee_increase = 0 and no creation options, while the same builder handed to sdk.document_create(builder, ...) gets the requested 42 and opts. Because user_fee_increase is part of the signed transition, this is a correctness gap, not cosmetics. The same fix applied at the one-shot call site should live inside the builder (or with_settings should extract the two fields up-front the way DocumentDeleteTransitionBuilder::with_settings (delete.rs:146-160) already does).

source: ['codex', 'claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/documents/transitions/create.rs`:
- [BLOCKING] lines 227-244: DocumentCreateTransitionBuilder::sign_with_nonce silently drops user_fee_increase / creation_options set via with_settings
  `DocumentCreateTransitionBuilder::with_settings` (create.rs:107-110) stores the incoming `PutSettings` whole into `self.settings`, but `sign_with_nonce` (create.rs:227-244) builds the transition off of `self.user_fee_increase.unwrap_or_default()` and `self.state_transition_creation_options` only — it never falls back to the values in `self.settings`. This PR teaches `Sdk::document_create` to fix that by destructuring the builder, overlaying `user_fee_increase` / `state_transition_creation_options` onto `effective_settings`, and passing it to `build_signed_document_create_transition` (create.rs:286-332). The result is an asymmetric foot-gun: a native two-phase caller who does `builder.with_settings(PutSettings { user_fee_increase: Some(42), state_transition_creation_options: Some(opts), .. }).sign(sdk, ...)` silently signs a transition with `user_fee_increase = 0` and no creation options, while the same builder handed to `sdk.document_create(builder, ...)` gets the requested 42 and opts. Because `user_fee_increase` is part of the signed transition, this is a correctness gap, not cosmetics. The same fix applied at the one-shot call site should live inside the builder (or `with_settings` should extract the two fields up-front the way `DocumentDeleteTransitionBuilder::with_settings` (delete.rs:146-160) already does).

Comment on lines 191 to 221
@@ -161,6 +215,8 @@ impl DocumentReplaceTransitionBuilder {
)
.await?;

ensure_valid_state_transition_structure(&state_transition, platform_version)?;

Ok(state_transition)
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: DocumentReplaceTransitionBuilder::sign_with_nonce silently drops user_fee_increase / creation_options set via with_settings

Same root cause as the create builder. with_settings (replace.rs:101-104) stores settings whole, but sign_with_nonce (replace.rs:191-221) reads only self.user_fee_increase and self.state_transition_creation_options. Meanwhile this PR added a Sdk::document_replace overlay (replace.rs:284-312) that merges the builder fields into effective_settings before signing. So a caller using .with_settings(PutSettings{ user_fee_increase: Some(N), .. }).sign(...) produces a different signed wire transition than .with_settings(...).then(sdk.document_replace(builder)). Because the fee increase is bound into the signed transition this can silently understate the fee bump and drop creation options on the prepare-style API. Fix by extracting the two fields inside with_settings (mirroring delete.rs:146-160) or by overlaying from self.settings inside sign_with_nonce.

source: ['codex', 'claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/documents/transitions/replace.rs`:
- [BLOCKING] lines 191-221: DocumentReplaceTransitionBuilder::sign_with_nonce silently drops user_fee_increase / creation_options set via with_settings
  Same root cause as the create builder. `with_settings` (replace.rs:101-104) stores settings whole, but `sign_with_nonce` (replace.rs:191-221) reads only `self.user_fee_increase` and `self.state_transition_creation_options`. Meanwhile this PR added a `Sdk::document_replace` overlay (replace.rs:284-312) that merges the builder fields into `effective_settings` before signing. So a caller using `.with_settings(PutSettings{ user_fee_increase: Some(N), .. }).sign(...)` produces a different signed wire transition than `.with_settings(...).then(sdk.document_replace(builder))`. Because the fee increase is bound into the signed transition this can silently understate the fee bump and drop creation options on the prepare-style API. Fix by extracting the two fields inside `with_settings` (mirroring delete.rs:146-160) or by overlaying from `self.settings` inside `sign_with_nonce`.

Comment on lines +215 to +225
)
.await?;

self.sign_with_nonce(
identity_contract_nonce,
identity_public_key,
signer,
platform_version,
)
.await
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: DocumentDeleteTransitionBuilder::sign leaks the allocated nonce on signing failure

DocumentCreateTransitionBuilder::sign (create.rs:176-195) and DocumentReplaceTransitionBuilder::sign (replace.rs:159-178) both wrap the sign_with_nonce call in a match and call sdk.rollback_identity_contract_nonce(...) on the error branch. DocumentDeleteTransitionBuilder::sign (delete.rs:202-225) only uses ? — a signing failure (e.g. signer error, document_type lookup failure inside sign_with_nonce) silently advances this SDK instance's nonce cache past a nonce the network never observes. The wasm-sdk prepareDocumentDelete/document_delete paths are protected because they route through build_signed_document_delete_transition which handles rollback, and the docstring at lines 184-189 acknowledges the gap and points users at sign_with_nonce for rollback control. But the builder shape matches create/replace exactly, so direct native callers reasonably expect symmetric behavior. Align sign with create/replace.

💡 Suggested change
Suggested change
)
.await?;
self.sign_with_nonce(
identity_contract_nonce,
identity_public_key,
signer,
platform_version,
)
.await
}
pub async fn sign(
&self,
sdk: &Sdk,
identity_public_key: &IdentityPublicKey,
signer: &impl Signer<IdentityPublicKey>,
platform_version: &PlatformVersion,
) -> Result<StateTransition, Error> {
let identity_contract_nonce = sdk
.get_identity_contract_nonce(
self.owner_id,
self.data_contract.id(),
true,
self.settings,
)
.await?;
match self
.sign_with_nonce(
identity_contract_nonce,
identity_public_key,
signer,
platform_version,
)
.await
{
Ok(transition) => Ok(transition),
Err(err) => {
sdk.rollback_identity_contract_nonce(
self.owner_id,
self.data_contract.id(),
identity_contract_nonce,
)
.await;
Err(err)
}
}
}

source: ['claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/documents/transitions/delete.rs`:
- [SUGGESTION] lines 202-225: DocumentDeleteTransitionBuilder::sign leaks the allocated nonce on signing failure
  `DocumentCreateTransitionBuilder::sign` (create.rs:176-195) and `DocumentReplaceTransitionBuilder::sign` (replace.rs:159-178) both wrap the `sign_with_nonce` call in a `match` and call `sdk.rollback_identity_contract_nonce(...)` on the error branch. `DocumentDeleteTransitionBuilder::sign` (delete.rs:202-225) only uses `?` — a signing failure (e.g. signer error, document_type lookup failure inside `sign_with_nonce`) silently advances this SDK instance's nonce cache past a nonce the network never observes. The wasm-sdk `prepareDocumentDelete`/`document_delete` paths are protected because they route through `build_signed_document_delete_transition` which handles rollback, and the docstring at lines 184-189 acknowledges the gap and points users at `sign_with_nonce` for rollback control. But the builder shape matches create/replace exactly, so direct native callers reasonably expect symmetric behavior. Align `sign` with create/replace.

Comment on lines +1341 to +1376
fn ensure_document_create_revision(
revision: Option<u64>,
replace_api_name: &str,
) -> Result<(), WasmSdkError> {
if let Some(revision) = revision {
if revision != INITIAL_REVISION {
return Err(WasmSdkError::invalid_argument(format!(
"Document revision is {} but create requires revision to be unset or {}. Use {} for existing documents.",
revision, INITIAL_REVISION, replace_api_name,
)));
}
}

Ok(())
}

fn ensure_document_replace_revision(
revision: Option<u64>,
create_api_name: &str,
) -> Result<(), WasmSdkError> {
let revision = revision.ok_or_else(|| {
WasmSdkError::invalid_argument(format!(
"Document must have a revision set for replace. Use {} for new documents.",
create_api_name,
))
})?;

if revision <= INITIAL_REVISION {
return Err(WasmSdkError::invalid_argument(format!(
"Document revision is {} but replace requires revision > {}. Use {} for new documents.",
revision, INITIAL_REVISION, create_api_name,
)));
}

Ok(())
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: WASM revision guards point users at sibling API that also rejects revision=0

ensure_document_create_revision collapses any non-INITIAL_REVISION value (including Some(0)) into "Use {replace_api_name} for existing documents", and ensure_document_replace_revision likewise tells callers of revision=0 to "Use {create_api_name} for new documents". After this PR, revision=0 is rejected on both paths (the rs-sdk side special-cases this via ensure_revision_nonzero), so the guidance is contradictory. JS callers who hit either error and follow the message will hit the same InvalidArgument on the other API. Add a dedicated arm for Some(0) that says revision 0 is invalid for both paths.

💡 Suggested change
Suggested change
fn ensure_document_create_revision(
revision: Option<u64>,
replace_api_name: &str,
) -> Result<(), WasmSdkError> {
if let Some(revision) = revision {
if revision != INITIAL_REVISION {
return Err(WasmSdkError::invalid_argument(format!(
"Document revision is {} but create requires revision to be unset or {}. Use {} for existing documents.",
revision, INITIAL_REVISION, replace_api_name,
)));
}
}
Ok(())
}
fn ensure_document_replace_revision(
revision: Option<u64>,
create_api_name: &str,
) -> Result<(), WasmSdkError> {
let revision = revision.ok_or_else(|| {
WasmSdkError::invalid_argument(format!(
"Document must have a revision set for replace. Use {} for new documents.",
create_api_name,
))
})?;
if revision <= INITIAL_REVISION {
return Err(WasmSdkError::invalid_argument(format!(
"Document revision is {} but replace requires revision > {}. Use {} for new documents.",
revision, INITIAL_REVISION, create_api_name,
)));
}
Ok(())
}
fn ensure_document_create_revision(
revision: Option<u64>,
replace_api_name: &str,
) -> Result<(), WasmSdkError> {
match revision {
None => Ok(()),
Some(rev) if rev == INITIAL_REVISION => Ok(()),
Some(0) => Err(WasmSdkError::invalid_argument(format!(
"Document revision 0 is invalid. Use an unset revision or {} for create, or a revision greater than {} for {}.",
INITIAL_REVISION, INITIAL_REVISION, replace_api_name,
))),
Some(revision) => Err(WasmSdkError::invalid_argument(format!(
"Document revision is {} but create requires revision to be unset or {}. Use {} for existing documents.",
revision, INITIAL_REVISION, replace_api_name,
))),
}
}
fn ensure_document_replace_revision(
revision: Option<u64>,
create_api_name: &str,
) -> Result<(), WasmSdkError> {
match revision {
Some(rev) if rev > INITIAL_REVISION => Ok(()),
Some(0) => Err(WasmSdkError::invalid_argument(format!(
"Document revision 0 is invalid. Use an unset revision or {} for {}, or a revision greater than {} for replace.",
INITIAL_REVISION, create_api_name, INITIAL_REVISION,
))),
Some(revision) => Err(WasmSdkError::invalid_argument(format!(
"Document revision is {} but replace requires revision > {}. Use {} for new documents.",
revision, INITIAL_REVISION, create_api_name,
))),
None => Err(WasmSdkError::invalid_argument(format!(
"Document must have a revision set for replace. Use {} for new documents.",
create_api_name,
))),
}
}

source: ['codex']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 1341-1376: WASM revision guards point users at sibling API that also rejects revision=0
  `ensure_document_create_revision` collapses any non-`INITIAL_REVISION` value (including `Some(0)`) into "Use {replace_api_name} for existing documents", and `ensure_document_replace_revision` likewise tells callers of revision=0 to "Use {create_api_name} for new documents". After this PR, revision=0 is rejected on both paths (the rs-sdk side special-cases this via `ensure_revision_nonzero`), so the guidance is contradictory. JS callers who hit either error and follow the message will hit the same `InvalidArgument` on the other API. Add a dedicated arm for `Some(0)` that says revision 0 is invalid for both paths.

Comment on lines +660 to +720
pub async fn prepare_document_create(
&self,
options: PrepareDocumentCreateOptionsJs,
) -> Result<StateTransitionWasm, WasmSdkError> {
// Extract document from options
let document_wasm = DocumentWasm::try_from_options(&options, "document")?;
let document: Document = document_wasm.clone().into();

ensure_document_create_revision(document.revision(), "prepareDocumentReplace")?;

// Get metadata from document
let contract_id: Identifier = document_wasm.data_contract_id().into();
let document_type_name = document_wasm.document_type_name();

// Get entropy from document
let entropy = document_wasm.entropy().ok_or_else(|| {
WasmSdkError::invalid_argument("Document must have entropy set for creation")
})?;

if entropy.len() != 32 {
return Err(WasmSdkError::invalid_argument(
"Document entropy must be exactly 32 bytes",
));
}

let mut entropy_array = [0u8; 32];
entropy_array.copy_from_slice(&entropy);

// Extract identity key from options
let identity_key_wasm = IdentityPublicKeyWasm::try_from_options(&options, "identityKey")?;
let identity_key: IdentityPublicKey = identity_key_wasm.into();

// Extract signer from options
let signer = IdentitySignerWasm::try_from_options(&options, "signer")?;

// Fetch the data contract (using cache)
let data_contract = self.get_or_fetch_contract(contract_id).await?;

// Get document type (owned)
let document_type = get_document_type(&data_contract, &document_type_name)?;

// Extract settings from options
let settings =
try_from_options_optional::<PutSettingsInput>(&options, "settings")?.map(Into::into);
let token_payment_info = try_from_options_optional_token_payment_info(&options)?;

// Build, sign, and structurally validate the state transition without
// broadcasting it. Local pre-broadcast failures are rolled back inside
// rs-sdk so the identity-contract nonce cache cannot advance past a
// nonce the network never observed.
let state_transition = build_signed_document_create_transition(
self.inner_sdk(),
&document,
&document_type,
entropy_array,
&identity_key,
token_payment_info,
&signer,
settings,
)
.await?;
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: prepareDocumentCreate performs network-backed contract fetch before rejecting invalid id/entropy

The new strict create-path invariant (ensure_document_id_matches_entropy) is supposed to make obviously invalid input fail fast. But prepareDocumentCreate awaits self.get_or_fetch_contract(contract_id) at line 696 before reaching build_signed_document_create_transition, where the id-vs-entropy check actually happens (after revision/id-match validation in build_signed_document_create_transition_owned). A caller with a malformed document still pays for network I/O and can see a transport error instead of InvalidArgument, despite the helper having all data needed to derive the expected id locally (dataContractId, ownerId, documentTypeName, entropy are all extracted before the fetch). The same ordering exists in document_create(). This weakens the new two-phase API for offline/retry-heavy clients.

source: ['codex']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 660-720: prepareDocumentCreate performs network-backed contract fetch before rejecting invalid id/entropy
  The new strict create-path invariant (`ensure_document_id_matches_entropy`) is supposed to make obviously invalid input fail fast. But `prepareDocumentCreate` awaits `self.get_or_fetch_contract(contract_id)` at line 696 before reaching `build_signed_document_create_transition`, where the id-vs-entropy check actually happens (after revision/id-match validation in `build_signed_document_create_transition_owned`). A caller with a malformed document still pays for network I/O and can see a transport error instead of `InvalidArgument`, despite the helper having all data needed to derive the expected id locally (`dataContractId`, `ownerId`, `documentTypeName`, `entropy` are all extracted before the fetch). The same ordering exists in `document_create()`. This weakens the new two-phase API for offline/retry-heavy clients.

Comment on lines +24 to +40
fn map_validation_result(result: SimpleConsensusValidationResult) -> Result<(), Error> {
if result.is_valid() {
return Ok(());
}

let real_errors: Vec<ConsensusError> = result
.errors
.into_iter()
.filter(|e| !is_unsupported_feature_error(e))
.collect();

if real_errors.is_empty() {
Ok(())
} else {
Err(SimpleConsensusValidationResult::new_with_errors(real_errors).into())
}
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: map_validation_result silently drops UnsupportedFeatureError entries from mixed error lists — call out in PR notes

Previously ensure_valid_state_transition_structure returned Ok only when every error was UnsupportedFeatureError, otherwise it surfaced the full validation result. The new map_validation_result filters UnsupportedFeatureError entries out and returns only the remaining errors (validation.rs:29-39). The new behavior is sensible (no "not implemented" noise mixed with real errors) and the test at lines 100-111 covers it. But callers that depended on Result.errors faithfully reflecting what DPP returned — error counts, ordered iteration, diagnostic logging — will see different shapes. This is a public behavior change beyond document transitions; mention it in the PR description's compatibility note.

source: ['claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/transition/validation.rs`:
- [SUGGESTION] lines 24-40: map_validation_result silently drops UnsupportedFeatureError entries from mixed error lists — call out in PR notes
  Previously `ensure_valid_state_transition_structure` returned `Ok` only when *every* error was `UnsupportedFeatureError`, otherwise it surfaced the full validation result. The new `map_validation_result` filters `UnsupportedFeatureError` entries out and returns only the remaining errors (validation.rs:29-39). The new behavior is sensible (no "not implemented" noise mixed with real errors) and the test at lines 100-111 covers it. But callers that depended on `Result.errors` faithfully reflecting what DPP returned — error counts, ordered iteration, diagnostic logging — will see different shapes. This is a public behavior change beyond document transitions; mention it in the PR description's compatibility note.

Comment on lines 154 to 176
return Ok(None);
}

let token_payment_info = TokenPaymentInfoWasm::constructor(
token_payment_info_value.unchecked_into::<TokenPaymentInfoOptionsJs>(),
)
.map_err(|err| WasmSdkError::invalid_argument(err.to_string()))?;
// Accept either an existing wasm-dpp2 `TokenPaymentInfo` class instance
// (e.g. produced by `new TokenPaymentInfo(...)` or returned by another
// wasm-sdk accessor) **or** a plain `DocumentTokenPaymentInfo` options
// bag. We detect the class type via the `__type` getter set up by
// `impl_wasm_type_info!` and convert via `TryFrom<&JsValue>` (which uses
// the `IntoWasm` pointer extraction) before falling back to the
// constructor-from-options path for plain objects.
let class_type = get_class_type(&token_payment_info_value).unwrap_or_default();
let token_payment_info = if class_type == "TokenPaymentInfo" {
TokenPaymentInfoWasm::try_from(&token_payment_info_value)
.map_err(|err| WasmSdkError::invalid_argument(err.to_string()))?
} else {
TokenPaymentInfoWasm::constructor(
token_payment_info_value.unchecked_into::<TokenPaymentInfoOptionsJs>(),
)
.map_err(|err| WasmSdkError::invalid_argument(err.to_string()))?
};

Ok(Some(token_payment_info.into()))
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: try_from_options_optional_token_payment_info decides ABI parsing path from attacker-controlled __type string

get_class_type (line 164) reads the __type getter set up by impl_wasm_type_info! to decide between two branches: if __type == "TokenPaymentInfo", the helper invokes TokenPaymentInfoWasm::try_from(&value), which routes through IntoWasm and reads the __wbg_ptr integer off the JS object before calling ref_from_abi. JS-side code (untrusted page or third-party package) can hand the WASM SDK a forged object like { __type: "TokenPaymentInfo", __wbg_ptr: 123456 } and force Rust to reinterpret an arbitrary integer as a TokenPaymentInfoWasm pointer inside the wasm linear-memory heap. The wasm sandbox prevents host-memory escape, but the type confusion can still cause wasm traps or read structurally-valid garbage as the payment info bound into the signed transition — a defensive boundary worth tightening. Two separate concerns: (1) gate the wrapper branch on a stronger identity check than a JS-readable getter, and (2) the else branch currently calls unchecked_into::<TokenPaymentInfoOptionsJs> on any non-"TokenPaymentInfo" class type (e.g. a related wasm-dpp2 wrapper), which will produce a hard-to-diagnose error or partially-decoded payment info — gate the constructor branch on class_type.is_empty() (plain object) and reject other class types explicitly.

source: ['codex', 'claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 142-176: try_from_options_optional_token_payment_info decides ABI parsing path from attacker-controlled __type string
  `get_class_type` (line 164) reads the `__type` getter set up by `impl_wasm_type_info!` to decide between two branches: if `__type == "TokenPaymentInfo"`, the helper invokes `TokenPaymentInfoWasm::try_from(&value)`, which routes through `IntoWasm` and reads the `__wbg_ptr` integer off the JS object before calling `ref_from_abi`. JS-side code (untrusted page or third-party package) can hand the WASM SDK a forged object like `{ __type: "TokenPaymentInfo", __wbg_ptr: 123456 }` and force Rust to reinterpret an arbitrary integer as a `TokenPaymentInfoWasm` pointer inside the wasm linear-memory heap. The wasm sandbox prevents host-memory escape, but the type confusion can still cause wasm traps or read structurally-valid garbage as the payment info bound into the signed transition — a defensive boundary worth tightening. Two separate concerns: (1) gate the wrapper branch on a stronger identity check than a JS-readable getter, and (2) the `else` branch currently calls `unchecked_into::<TokenPaymentInfoOptionsJs>` on any non-`"TokenPaymentInfo"` class type (e.g. a related wasm-dpp2 wrapper), which will produce a hard-to-diagnose error or partially-decoded payment info — gate the constructor branch on `class_type.is_empty()` (plain object) and reject other class types explicitly.

Comment on lines +511 to +532
pub async fn build_signed_document_replace_transition<S: Signer<IdentityPublicKey>>(
sdk: &Sdk,
document: &Document,
document_type: &DocumentType,
identity_public_key: &IdentityPublicKey,
token_payment_info: Option<TokenPaymentInfo>,
signer: &S,
settings: Option<PutSettings>,
) -> Result<StateTransition, Error> {
ensure_revision_for_replace(document.revision())?;
build_signed_document_create_or_replace_transition_legacy(
sdk,
document,
document_type,
None, // entropy is unused on the replace path
identity_public_key,
token_payment_info,
signer,
settings,
)
.await
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Strict replace helper re-routes through the deprecated legacy dispatcher

build_signed_document_replace_transition validates revision > INITIAL_REVISION and then calls build_signed_document_create_or_replace_transition_legacy(..., None, ...). That legacy dispatcher re-runs ensure_revision_nonzero and then dispatches into build_and_sign_create_or_replace_after_nonce, which carries the unreachable create branch (entropy resolution, doc id rewrite) for the sole purpose of choosing the replace path on revisions > INITIAL_REVISION. For symmetry with build_signed_document_create_transition / build_signed_document_create_transition_owned, add a dedicated build_signed_document_replace_transition_inner (validate → allocate nonce → new_document_replacement_transition_from_document → validate structure → rollback on err). As written, the strict replace path keeps an architectural dependency on the dispatcher it supersedes.

source: ['claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 511-532: Strict replace helper re-routes through the deprecated legacy dispatcher
  `build_signed_document_replace_transition` validates `revision > INITIAL_REVISION` and then calls `build_signed_document_create_or_replace_transition_legacy(..., None, ...)`. That legacy dispatcher re-runs `ensure_revision_nonzero` and then dispatches into `build_and_sign_create_or_replace_after_nonce`, which carries the unreachable create branch (entropy resolution, doc id rewrite) for the sole purpose of choosing the replace path on revisions > INITIAL_REVISION. For symmetry with `build_signed_document_create_transition` / `build_signed_document_create_transition_owned`, add a dedicated `build_signed_document_replace_transition_inner` (validate → allocate nonce → `new_document_replacement_transition_from_document` → validate structure → rollback on err). As written, the strict replace path keeps an architectural dependency on the dispatcher it supersedes.

Comment on lines +267 to +291
#[deprecated(
note = "use build_signed_document_create_transition or build_signed_document_replace_transition for strict intent validation"
)]
pub async fn build_signed_document_create_or_replace_transition<S: Signer<IdentityPublicKey>>(
sdk: &Sdk,
document: &Document,
document_type: &DocumentType,
document_state_transition_entropy: Option<[u8; 32]>,
identity_public_key: &IdentityPublicKey,
token_payment_info: Option<TokenPaymentInfo>,
signer: &S,
settings: Option<PutSettings>,
) -> Result<StateTransition, Error> {
build_signed_document_create_or_replace_transition_legacy(
sdk,
document,
document_type,
document_state_transition_entropy,
identity_public_key,
token_payment_info,
signer,
settings,
)
.await
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Newly introduced public helper build_signed_document_create_or_replace_transition is #[deprecated] from day one

build_signed_document_create_or_replace_transition is introduced as pub in this PR with #[deprecated] already applied, and the docstring says it is "retained as a public, source-compatible entry point for native callers that already depend on it." There are no such callers — the function is new, and all in-tree call sites use the strict helpers or call the private *_legacy impl directly. Exposing a deprecated public function from day one expands API surface for no concrete consumer. Keep build_signed_document_create_or_replace_transition_legacy private and re-add a public wrapper only when an external caller actually needs it.

source: ['claude']

Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

PR #3091 adds well-structured prepare_* document state transition APIs with careful nonce rollback semantics and thorough test coverage. The reviewed worktree shows no consensus-critical changes and no security regressions introduced by this PR. The most actionable issue is a stale assertion in two wasm unit tests that will fail because the new Rust revision-0 message intentionally omits the sibling-API name. Other findings are suggestions or doc nits. Two reviewer findings were dropped: codex-security's protocol-version-poisoning claim targets pre-existing code in sdk.rs that this PR does not modify, and a low-value performance nit.

Reviewed commit: 730f6b3

🟡 6 suggestion(s) | 💬 4 nitpick(s)

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/wasm-sdk/tests/unit/prepare-document.spec.ts`:
- [SUGGESTION] lines 138-188: Revision-0 wasm tests still assert the old sibling-API wording and will fail
  Both revision-0 tests still expect the error message to mention the sibling API: line 152 matches `/prepareDocumentReplace/` and line 186 matches `/prepareDocumentCreate/`. This PR changed the Rust guards (`ensure_document_create_revision` / `ensure_document_replace_revision` at packages/wasm-sdk/src/state_transitions/document.rs:1459-1463 and 1481-1484) to emit a dedicated message for revision 0 that intentionally does NOT name the sibling API — the comment in the Rust source explicitly says "do not point users at the sibling API." These two assertions will fail as soon as the unit suite is run. Update the matchers to the new shared message (`revision 0 is invalid for both create and replace`) and assert that the sibling-API name is absent.

In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 955-993: Prepare/document delete entry points dereference attacker-controlled `__wbg_ptr` while sibling tokenPaymentInfo path is hardened
  `prepare_document_delete` (and the existing `document_delete`) branches on `get_class_type(&document_js) == Some("Document")` and then calls `document_js.to_wasm::<DocumentWasm>("Document")`. That path resolves to `generic_of_js_val` in packages/wasm-dpp2/src/utils.rs:114-141, which reads the JS-controlled `__wbg_ptr` property and invokes `unsafe { T::ref_from_abi(ptr_u32) }` after checking only the also-JS-controlled `__type` string. A forged JS object `{ __type: "Document", __wbg_ptr: <attacker u32> }` therefore materializes a `&DocumentWasm` from arbitrary wasm linear-memory bytes — undefined behavior per wasm-bindgen's `RefFromWasmAbi` contract.

This is the exact attack class the PR explicitly defends against for `tokenPaymentInfo` a few hundred lines earlier (packages/wasm-sdk/src/state_transitions/document.rs:142-194), with the security comment stating: "A forged object can spoof the public `__type` getter/string, but it cannot force us to dereference an arbitrary wasm pointer when we only copy public fields into a fresh options bag." The same threat model applies to `document` on every prepare entry point this PR adds (`prepare_document_create` / `prepare_document_replace` reach the same code via `DocumentWasm::try_from_options`). The pattern is consistent with pre-existing `document_create` / `document_replace` / `document_delete`, so this is not a regression, but the PR expands the attack surface with three new entry points while leaving the safer pattern unused for `Document`. Apply the same Reflect-and-copy-public-fields defense to the `document` branch, or accept only structural objects (`{ id, ownerId, dataContractId, documentTypeName, revision? }`) on the prepare APIs.

In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 117-167: Strict create-path id check hard-pins to v0 derivation despite project-wide PlatformVersion dispatch
  `derive_document_id_v0` and `ensure_document_id_matches_entropy` are hard-pinned to `Document::generate_document_id_v0`. The helper's doc comment acknowledges this and instructs future readers to grep both names if the derivation ever changes. The risk is that this helper is the new public pre-broadcast gate for `build_signed_document_create_transition` (and thus `prepareDocumentCreate`), so if a future protocol version introduces a new id derivation, every caller on the strict path will start rejecting otherwise-valid creates locally as `InvalidArgument` rather than letting the consensus rules decide. Given the rest of the platform consistently dispatches on `PlatformVersion`, the more defensive shape is to branch internally on the SDK's current protocol version here instead of relying on a future grep. Not a defect today, but worth either adding the version dispatch now or adding a TODO that ties the migration to a specific consensus-version bump.

In `packages/rs-sdk/src/platform/documents/transitions/create.rs`:
- [SUGGESTION] lines 123-137: `with_settings` body duplicated verbatim across create/replace/delete builders
  The new `with_settings` implementation is identical line-for-line in create.rs, replace.rs, and delete.rs (extract `user_fee_increase` and `state_transition_creation_options` into dedicated fields only when `None`, clear those two `PutSettings` fields, store the remainder). The behavioral contract is non-trivial (explicit setter wins, single source of truth, preserve other fields) and is currently maintained by copying the same code into three files plus three nearly-identical test suites. The failure mode this invites is divergence — someone adding a new `PutSettings` field that needs the same treatment, or fixing a bug in one but not the others. Extract this into a single helper (e.g. an inherent `fn split_dedicated_fields(self) -> (Option<UserFeeIncrease>, Option<StateTransitionCreationOptions>, PutSettings)` on `PutSettings`) so the three builder methods reduce to one or two lines that call it. The test tables can likewise be parameterized.

In `packages/rs-sdk/src/platform/transition/validation.rs`:
- [SUGGESTION] lines 17-40: `UnsupportedFeatureError` filter assumes the variant is only a "not implemented" placeholder
  `map_validation_result` filters every `UnsupportedFeatureError` out of mixed structure-validation results and treats an all-unsupported result as `Ok`. That is correct for identity-transition arms in `rs-dpp/src/state_transition/mod.rs:1418-1426`, but DPP also uses `UnsupportedFeatureError` as a legitimate consensus rejection — e.g. `TokenConfigUpdateTransition::validate_structure_v0` rejects perpetual-distribution changes with `UnsupportedFeatureError::new("of changing perpetual distribution", ...)`. Now that this helper is `pub` and called from many transition paths (transfer, vote, withdrawal, put_contract, purchase_document, etc.), a future `BatchTransition` that mixes a document op with an unsupported token-config update would have its real rejection silently filtered and only surface server-side after broadcast. Within this PR's scope the prepare flows only build single-document batches, so the concern does not materialize today — but the filter should either be tightened to acknowledge the second meaning or narrowed to the specific transition kinds where it's known safe (identity-based STs).

In `packages/rs-sdk/README.md`:
- [SUGGESTION] lines 49-55: rs-sdk compatibility note omits the new id-vs-entropy invariant for native callers
  The compatibility section calls out the new `with_settings` pass-through behavior and the delete-builder rollback + structure-validation additions, but does not mention the new id-vs-entropy invariant enforced inside `DocumentCreateTransitionBuilder::sign_with_nonce` (create.rs:243-279) and `PutDocument::put_to_platform` (put_document.rs:704-733). Native rs-sdk callers that previously built a `DocumentCreateTransitionBuilder`, or called `put_to_platform` with `entropy = Some(..)`, with a hand-picked `document.id` that did not match `Document::generate_document_id_v0(...)` will now see `Error::InvalidArgument` instead of a silently signed transition. The README is the durable reference for external consumers and should call this out alongside the other create/replace/delete behavior changes.

Comment on lines +138 to +188
it('rejects a document with revision 0 (would silently be a replace)', async () => {
// `build_document_create_or_replace_transition` routes any `revision != INITIAL_REVISION`
// to the replace branch, so `revision = 0` must be rejected too — otherwise it would
// silently produce a replace transition instead of a create.
const { signer, identityKey } = buildSigner();
const document = buildDocument({ revision: 0 });

try {
await client.prepareDocumentCreate({ document, identityKey, signer });
expect.fail('expected prepareDocumentCreate to reject revision 0');
} catch (e) {
expect(e).to.be.instanceOf(sdk.WasmSdkError);
expect(e.name).to.equal('InvalidArgument');
expect(e.message).to.match(/revision/i);
expect(e.message).to.match(/prepareDocumentReplace/);
}
});
});

describe('prepareDocumentReplace()', () => {
it('rejects a document with no revision', async () => {
const { signer, identityKey } = buildSigner();
const document = buildDocument();
// The Document constructor defaults revision to 1; clear it to exercise
// the "must have a revision set" guard in prepare_document_replace.
document.revision = null;

try {
await client.prepareDocumentReplace({ document, identityKey, signer });
expect.fail('expected prepareDocumentReplace to reject missing revision');
} catch (e) {
expect(e).to.be.instanceOf(sdk.WasmSdkError);
expect(e.name).to.equal('InvalidArgument');
expect(e.message).to.match(/revision/i);
}
});

it('rejects a document with revision 0', async () => {
const { signer, identityKey } = buildSigner();
const document = buildDocument({ revision: 0 });

try {
await client.prepareDocumentReplace({ document, identityKey, signer });
expect.fail('expected prepareDocumentReplace to reject revision 0');
} catch (e) {
expect(e).to.be.instanceOf(sdk.WasmSdkError);
expect(e.name).to.equal('InvalidArgument');
expect(e.message).to.match(/revision/i);
expect(e.message).to.match(/prepareDocumentCreate/);
}
});
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Revision-0 wasm tests still assert the old sibling-API wording and will fail

Both revision-0 tests still expect the error message to mention the sibling API: line 152 matches /prepareDocumentReplace/ and line 186 matches /prepareDocumentCreate/. This PR changed the Rust guards (ensure_document_create_revision / ensure_document_replace_revision at packages/wasm-sdk/src/state_transitions/document.rs:1459-1463 and 1481-1484) to emit a dedicated message for revision 0 that intentionally does NOT name the sibling API — the comment in the Rust source explicitly says "do not point users at the sibling API." These two assertions will fail as soon as the unit suite is run. Update the matchers to the new shared message (revision 0 is invalid for both create and replace) and assert that the sibling-API name is absent.

💡 Suggested change
Suggested change
it('rejects a document with revision 0 (would silently be a replace)', async () => {
// `build_document_create_or_replace_transition` routes any `revision != INITIAL_REVISION`
// to the replace branch, so `revision = 0` must be rejected too — otherwise it would
// silently produce a replace transition instead of a create.
const { signer, identityKey } = buildSigner();
const document = buildDocument({ revision: 0 });
try {
await client.prepareDocumentCreate({ document, identityKey, signer });
expect.fail('expected prepareDocumentCreate to reject revision 0');
} catch (e) {
expect(e).to.be.instanceOf(sdk.WasmSdkError);
expect(e.name).to.equal('InvalidArgument');
expect(e.message).to.match(/revision/i);
expect(e.message).to.match(/prepareDocumentReplace/);
}
});
});
describe('prepareDocumentReplace()', () => {
it('rejects a document with no revision', async () => {
const { signer, identityKey } = buildSigner();
const document = buildDocument();
// The Document constructor defaults revision to 1; clear it to exercise
// the "must have a revision set" guard in prepare_document_replace.
document.revision = null;
try {
await client.prepareDocumentReplace({ document, identityKey, signer });
expect.fail('expected prepareDocumentReplace to reject missing revision');
} catch (e) {
expect(e).to.be.instanceOf(sdk.WasmSdkError);
expect(e.name).to.equal('InvalidArgument');
expect(e.message).to.match(/revision/i);
}
});
it('rejects a document with revision 0', async () => {
const { signer, identityKey } = buildSigner();
const document = buildDocument({ revision: 0 });
try {
await client.prepareDocumentReplace({ document, identityKey, signer });
expect.fail('expected prepareDocumentReplace to reject revision 0');
} catch (e) {
expect(e).to.be.instanceOf(sdk.WasmSdkError);
expect(e.name).to.equal('InvalidArgument');
expect(e.message).to.match(/revision/i);
expect(e.message).to.match(/prepareDocumentCreate/);
}
});
it('rejects a document with revision 0 (would silently be a replace)', async () => {
const { signer, identityKey } = buildSigner();
const document = buildDocument({ revision: 0 });
try {
await client.prepareDocumentCreate({ document, identityKey, signer });
expect.fail('expected prepareDocumentCreate to reject revision 0');
} catch (e) {
expect(e).to.be.instanceOf(sdk.WasmSdkError);
expect(e.name).to.equal('InvalidArgument');
expect(e.message).to.match(/revision 0 is invalid for both create and replace/i);
expect(e.message).to.not.match(/prepareDocumentReplace/);
}
});
});
describe('prepareDocumentReplace()', () => {
it('rejects a document with no revision', async () => {
const { signer, identityKey } = buildSigner();
const document = buildDocument();
document.revision = null;
try {
await client.prepareDocumentReplace({ document, identityKey, signer });
expect.fail('expected prepareDocumentReplace to reject missing revision');
} catch (e) {
expect(e).to.be.instanceOf(sdk.WasmSdkError);
expect(e.name).to.equal('InvalidArgument');
expect(e.message).to.match(/revision/i);
}
});
it('rejects a document with revision 0', async () => {
const { signer, identityKey } = buildSigner();
const document = buildDocument({ revision: 0 });
try {
await client.prepareDocumentReplace({ document, identityKey, signer });
expect.fail('expected prepareDocumentReplace to reject revision 0');
} catch (e) {
expect(e).to.be.instanceOf(sdk.WasmSdkError);
expect(e.name).to.equal('InvalidArgument');
expect(e.message).to.match(/revision 0 is invalid for both create and replace/i);
expect(e.message).to.not.match(/prepareDocumentCreate/);
}

source: ['codex']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/wasm-sdk/tests/unit/prepare-document.spec.ts`:
- [SUGGESTION] lines 138-188: Revision-0 wasm tests still assert the old sibling-API wording and will fail
  Both revision-0 tests still expect the error message to mention the sibling API: line 152 matches `/prepareDocumentReplace/` and line 186 matches `/prepareDocumentCreate/`. This PR changed the Rust guards (`ensure_document_create_revision` / `ensure_document_replace_revision` at packages/wasm-sdk/src/state_transitions/document.rs:1459-1463 and 1481-1484) to emit a dedicated message for revision 0 that intentionally does NOT name the sibling API — the comment in the Rust source explicitly says "do not point users at the sibling API." These two assertions will fail as soon as the unit suite is run. Update the matchers to the new shared message (`revision 0 is invalid for both create and replace`) and assert that the sibling-API name is absent.

Comment on lines +955 to +993
pub async fn prepare_document_delete(
&self,
options: PrepareDocumentDeleteOptionsJs,
) -> Result<StateTransitionWasm, WasmSdkError> {
// Extract document field - can be either a Document instance or plain object
let document_js = js_sys::Reflect::get(&options, &JsValue::from_str("document"))
.map_err(|_| WasmSdkError::invalid_argument("document is required"))?;

if document_js.is_undefined() || document_js.is_null() {
return Err(WasmSdkError::invalid_argument("document is required"));
}

// Check if it's a Document instance or a plain object with fields
let (document_id, owner_id, contract_id, document_type_name): (
Identifier,
Identifier,
Identifier,
String,
) = if get_class_type(&document_js).ok().as_deref() == Some("Document") {
let doc: DocumentWasm = document_js
.to_wasm::<DocumentWasm>("Document")
.map(|boxed| (*boxed).clone())?;
let doc_inner: Document = doc.clone().into();
(
doc.id().into(),
doc_inner.owner_id(),
doc.data_contract_id().into(),
doc.document_type_name(),
)
} else {
(
IdentifierWasm::try_from_options(&document_js, "id")?.into(),
IdentifierWasm::try_from_options(&document_js, "ownerId")?.into(),
IdentifierWasm::try_from_options(&document_js, "dataContractId")?.into(),
try_from_options_with(&document_js, "documentTypeName", |v| {
try_to_string(v, "documentTypeName")
})?,
)
};
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Prepare/document delete entry points dereference attacker-controlled __wbg_ptr while sibling tokenPaymentInfo path is hardened

prepare_document_delete (and the existing document_delete) branches on get_class_type(&document_js) == Some("Document") and then calls document_js.to_wasm::<DocumentWasm>("Document"). That path resolves to generic_of_js_val in packages/wasm-dpp2/src/utils.rs:114-141, which reads the JS-controlled __wbg_ptr property and invokes unsafe { T::ref_from_abi(ptr_u32) } after checking only the also-JS-controlled __type string. A forged JS object { __type: "Document", __wbg_ptr: <attacker u32> } therefore materializes a &DocumentWasm from arbitrary wasm linear-memory bytes — undefined behavior per wasm-bindgen's RefFromWasmAbi contract.

This is the exact attack class the PR explicitly defends against for tokenPaymentInfo a few hundred lines earlier (packages/wasm-sdk/src/state_transitions/document.rs:142-194), with the security comment stating: "A forged object can spoof the public __type getter/string, but it cannot force us to dereference an arbitrary wasm pointer when we only copy public fields into a fresh options bag." The same threat model applies to document on every prepare entry point this PR adds (prepare_document_create / prepare_document_replace reach the same code via DocumentWasm::try_from_options). The pattern is consistent with pre-existing document_create / document_replace / document_delete, so this is not a regression, but the PR expands the attack surface with three new entry points while leaving the safer pattern unused for Document. Apply the same Reflect-and-copy-public-fields defense to the document branch, or accept only structural objects ({ id, ownerId, dataContractId, documentTypeName, revision? }) on the prepare APIs.

source: ['claude', 'codex']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 955-993: Prepare/document delete entry points dereference attacker-controlled `__wbg_ptr` while sibling tokenPaymentInfo path is hardened
  `prepare_document_delete` (and the existing `document_delete`) branches on `get_class_type(&document_js) == Some("Document")` and then calls `document_js.to_wasm::<DocumentWasm>("Document")`. That path resolves to `generic_of_js_val` in packages/wasm-dpp2/src/utils.rs:114-141, which reads the JS-controlled `__wbg_ptr` property and invokes `unsafe { T::ref_from_abi(ptr_u32) }` after checking only the also-JS-controlled `__type` string. A forged JS object `{ __type: "Document", __wbg_ptr: <attacker u32> }` therefore materializes a `&DocumentWasm` from arbitrary wasm linear-memory bytes — undefined behavior per wasm-bindgen's `RefFromWasmAbi` contract.

This is the exact attack class the PR explicitly defends against for `tokenPaymentInfo` a few hundred lines earlier (packages/wasm-sdk/src/state_transitions/document.rs:142-194), with the security comment stating: "A forged object can spoof the public `__type` getter/string, but it cannot force us to dereference an arbitrary wasm pointer when we only copy public fields into a fresh options bag." The same threat model applies to `document` on every prepare entry point this PR adds (`prepare_document_create` / `prepare_document_replace` reach the same code via `DocumentWasm::try_from_options`). The pattern is consistent with pre-existing `document_create` / `document_replace` / `document_delete`, so this is not a regression, but the PR expands the attack surface with three new entry points while leaving the safer pattern unused for `Document`. Apply the same Reflect-and-copy-public-fields defense to the `document` branch, or accept only structural objects (`{ id, ownerId, dataContractId, documentTypeName, revision? }`) on the prepare APIs.

Comment on lines +117 to +167
/// # v0 versioning note
///
/// This helper is intentionally hard-pinned to
/// [`Document::generate_document_id_v0`]. A future protocol version that
/// introduces a new document-id derivation should change this helper (or
/// branch internally on platform version) — grep for `derive_document_id_v0`
/// **and** `generate_document_id_v0` to catch every call site that needs to
/// migrate together.
fn derive_document_id_v0(
document_type: DocumentTypeRef<'_>,
owner_id: &Identifier,
entropy: &[u8; 32],
) -> Identifier {
Document::generate_document_id_v0(
&document_type.data_contract_id(),
owner_id,
document_type.name(),
entropy.as_slice(),
)
}

/// Strict create-path id check: documents handed to
/// [`build_signed_document_create_transition`] must already have their `id`
/// derived from the supplied entropy via [`derive_document_id_v0`] (currently
/// [`Document::generate_document_id_v0`]).
///
/// This guards against silently signing a transition whose committed
/// document id does not match the entropy bound into the create transition.
/// Callers that want id auto-generation should use the legacy
/// [`PutDocument::put_to_platform`] trait method, which still accepts
/// `entropy = None` and rewrites the document id before signing.
pub(crate) fn ensure_document_id_matches_entropy(
document: &Document,
document_type: DocumentTypeRef<'_>,
entropy: &[u8; 32],
) -> Result<(), Error> {
let expected = derive_document_id_v0(document_type, &document.owner_id(), entropy);
if document.id() != expected {
return Err(Error::InvalidArgument(format!(
"document.id does not match \
generate_document_id_v0(contract_id, owner_id, document_type_name, entropy); \
expected {expected}, got {got}. \
Either set document.id to the derived value before calling \
build_signed_document_create_transition, or use the legacy \
PutDocument::put_to_platform trait method which auto-generates \
entropy and rewrites the document id when entropy is None.",
got = document.id()
)));
}
Ok(())
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Strict create-path id check hard-pins to v0 derivation despite project-wide PlatformVersion dispatch

derive_document_id_v0 and ensure_document_id_matches_entropy are hard-pinned to Document::generate_document_id_v0. The helper's doc comment acknowledges this and instructs future readers to grep both names if the derivation ever changes. The risk is that this helper is the new public pre-broadcast gate for build_signed_document_create_transition (and thus prepareDocumentCreate), so if a future protocol version introduces a new id derivation, every caller on the strict path will start rejecting otherwise-valid creates locally as InvalidArgument rather than letting the consensus rules decide. Given the rest of the platform consistently dispatches on PlatformVersion, the more defensive shape is to branch internally on the SDK's current protocol version here instead of relying on a future grep. Not a defect today, but worth either adding the version dispatch now or adding a TODO that ties the migration to a specific consensus-version bump.

source: ['codex']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 117-167: Strict create-path id check hard-pins to v0 derivation despite project-wide PlatformVersion dispatch
  `derive_document_id_v0` and `ensure_document_id_matches_entropy` are hard-pinned to `Document::generate_document_id_v0`. The helper's doc comment acknowledges this and instructs future readers to grep both names if the derivation ever changes. The risk is that this helper is the new public pre-broadcast gate for `build_signed_document_create_transition` (and thus `prepareDocumentCreate`), so if a future protocol version introduces a new id derivation, every caller on the strict path will start rejecting otherwise-valid creates locally as `InvalidArgument` rather than letting the consensus rules decide. Given the rest of the platform consistently dispatches on `PlatformVersion`, the more defensive shape is to branch internally on the SDK's current protocol version here instead of relying on a future grep. Not a defect today, but worth either adding the version dispatch now or adding a TODO that ties the migration to a specific consensus-version bump.

Comment on lines +123 to +137
/// # Returns
///
/// * `Self` - The updated builder
pub fn with_settings(mut self, settings: PutSettings) -> Self {
pub fn with_settings(mut self, mut settings: PutSettings) -> Self {
if self.user_fee_increase.is_none() {
self.user_fee_increase = settings.user_fee_increase;
}
if self.state_transition_creation_options.is_none() {
self.state_transition_creation_options = settings.state_transition_creation_options;
}
// Strip the fee/creation-options fields from the stored settings so
// the dedicated builder fields are the sole source of truth at
// sign time. The remainder of `settings` flows through unchanged.
settings.user_fee_increase = None;
settings.state_transition_creation_options = None;
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: with_settings body duplicated verbatim across create/replace/delete builders

The new with_settings implementation is identical line-for-line in create.rs, replace.rs, and delete.rs (extract user_fee_increase and state_transition_creation_options into dedicated fields only when None, clear those two PutSettings fields, store the remainder). The behavioral contract is non-trivial (explicit setter wins, single source of truth, preserve other fields) and is currently maintained by copying the same code into three files plus three nearly-identical test suites. The failure mode this invites is divergence — someone adding a new PutSettings field that needs the same treatment, or fixing a bug in one but not the others. Extract this into a single helper (e.g. an inherent fn split_dedicated_fields(self) -> (Option<UserFeeIncrease>, Option<StateTransitionCreationOptions>, PutSettings) on PutSettings) so the three builder methods reduce to one or two lines that call it. The test tables can likewise be parameterized.

source: ['claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/documents/transitions/create.rs`:
- [SUGGESTION] lines 123-137: `with_settings` body duplicated verbatim across create/replace/delete builders
  The new `with_settings` implementation is identical line-for-line in create.rs, replace.rs, and delete.rs (extract `user_fee_increase` and `state_transition_creation_options` into dedicated fields only when `None`, clear those two `PutSettings` fields, store the remainder). The behavioral contract is non-trivial (explicit setter wins, single source of truth, preserve other fields) and is currently maintained by copying the same code into three files plus three nearly-identical test suites. The failure mode this invites is divergence — someone adding a new `PutSettings` field that needs the same treatment, or fixing a bug in one but not the others. Extract this into a single helper (e.g. an inherent `fn split_dedicated_fields(self) -> (Option<UserFeeIncrease>, Option<StateTransitionCreationOptions>, PutSettings)` on `PutSettings`) so the three builder methods reduce to one or two lines that call it. The test tables can likewise be parameterized.

Comment on lines +17 to +40
/// Filter `UnsupportedFeatureError` entries out of a structure-validation
/// result and convert any remaining errors into [`Error`].
///
/// `UnsupportedFeatureError` only signals that structure validation is not yet
/// implemented for that state transition kind, so it must never mask a real
/// validation failure. If the result becomes empty after filtering we treat it
/// as a no-op pass.
fn map_validation_result(result: SimpleConsensusValidationResult) -> Result<(), Error> {
if result.is_valid() {
return Ok(());
}

let real_errors: Vec<ConsensusError> = result
.errors
.into_iter()
.filter(|e| !is_unsupported_feature_error(e))
.collect();

if real_errors.is_empty() {
Ok(())
} else {
Err(SimpleConsensusValidationResult::new_with_errors(real_errors).into())
}
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: UnsupportedFeatureError filter assumes the variant is only a "not implemented" placeholder

map_validation_result filters every UnsupportedFeatureError out of mixed structure-validation results and treats an all-unsupported result as Ok. That is correct for identity-transition arms in rs-dpp/src/state_transition/mod.rs:1418-1426, but DPP also uses UnsupportedFeatureError as a legitimate consensus rejection — e.g. TokenConfigUpdateTransition::validate_structure_v0 rejects perpetual-distribution changes with UnsupportedFeatureError::new("of changing perpetual distribution", ...). Now that this helper is pub and called from many transition paths (transfer, vote, withdrawal, put_contract, purchase_document, etc.), a future BatchTransition that mixes a document op with an unsupported token-config update would have its real rejection silently filtered and only surface server-side after broadcast. Within this PR's scope the prepare flows only build single-document batches, so the concern does not materialize today — but the filter should either be tightened to acknowledge the second meaning or narrowed to the specific transition kinds where it's known safe (identity-based STs).

source: ['claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/transition/validation.rs`:
- [SUGGESTION] lines 17-40: `UnsupportedFeatureError` filter assumes the variant is only a "not implemented" placeholder
  `map_validation_result` filters every `UnsupportedFeatureError` out of mixed structure-validation results and treats an all-unsupported result as `Ok`. That is correct for identity-transition arms in `rs-dpp/src/state_transition/mod.rs:1418-1426`, but DPP also uses `UnsupportedFeatureError` as a legitimate consensus rejection — e.g. `TokenConfigUpdateTransition::validate_structure_v0` rejects perpetual-distribution changes with `UnsupportedFeatureError::new("of changing perpetual distribution", ...)`. Now that this helper is `pub` and called from many transition paths (transfer, vote, withdrawal, put_contract, purchase_document, etc.), a future `BatchTransition` that mixes a document op with an unsupported token-config update would have its real rejection silently filtered and only surface server-side after broadcast. Within this PR's scope the prepare flows only build single-document batches, so the concern does not materialize today — but the filter should either be tightened to acknowledge the second meaning or narrowed to the specific transition kinds where it's known safe (identity-based STs).

Comment thread packages/rs-sdk/README.md
Comment on lines +49 to +55
## Compatibility notes

- `DocumentDeleteTransitionBuilder::with_settings(settings).sign(...)` now honors `settings.user_fee_increase` and `settings.state_transition_creation_options` when those builder fields were not set directly. Native rs-sdk callers that passed delete transition fee/creation options through `PutSettings` may now see those intended values take effect on the wire instead of being silently dropped.
- `DocumentCreateTransitionBuilder` and `DocumentReplaceTransitionBuilder` apply the same rule as the delete builder: `with_settings(settings).sign_with_nonce(...)` (and `.sign(...)`) now honor `settings.user_fee_increase` and `settings.state_transition_creation_options` when those builder fields were not set directly. Explicit `with_user_fee_increase` / `with_state_transition_creation_options` calls still win regardless of order. Native callers that previously passed these via `PutSettings` should expect them to take effect on the wire.
- `DocumentDeleteTransitionBuilder::sign` now rolls back the allocated identity-contract nonce on pre-broadcast failure (matching `DocumentCreateTransitionBuilder::sign` / `DocumentReplaceTransitionBuilder::sign`). `DocumentDeleteTransitionBuilder::sign_with_nonce` now also runs `ensure_valid_state_transition_structure` before returning, so direct builder users get the same pre-broadcast guarantees as the shared `build_signed_document_delete_transition` helper.
- `ensure_valid_state_transition_structure` (used by the wasm-sdk `prepareDocument*` flows and the rs-sdk document create/replace/delete builders) filters `UnsupportedFeatureError` entries out of mixed structure-validation results before surfacing the remaining errors. This intentionally lets identity-based state transitions, which DPP's structure validator still reports as `UnsupportedFeatureError`, through to broadcast where execution-time validation runs. Callers that previously relied on `UnsupportedFeatureError` surfacing through this path will no longer see those entries — only the real validation failures.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: rs-sdk compatibility note omits the new id-vs-entropy invariant for native callers

The compatibility section calls out the new with_settings pass-through behavior and the delete-builder rollback + structure-validation additions, but does not mention the new id-vs-entropy invariant enforced inside DocumentCreateTransitionBuilder::sign_with_nonce (create.rs:243-279) and PutDocument::put_to_platform (put_document.rs:704-733). Native rs-sdk callers that previously built a DocumentCreateTransitionBuilder, or called put_to_platform with entropy = Some(..), with a hand-picked document.id that did not match Document::generate_document_id_v0(...) will now see Error::InvalidArgument instead of a silently signed transition. The README is the durable reference for external consumers and should call this out alongside the other create/replace/delete behavior changes.

source: ['claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/README.md`:
- [SUGGESTION] lines 49-55: rs-sdk compatibility note omits the new id-vs-entropy invariant for native callers
  The compatibility section calls out the new `with_settings` pass-through behavior and the delete-builder rollback + structure-validation additions, but does not mention the new id-vs-entropy invariant enforced inside `DocumentCreateTransitionBuilder::sign_with_nonce` (create.rs:243-279) and `PutDocument::put_to_platform` (put_document.rs:704-733). Native rs-sdk callers that previously built a `DocumentCreateTransitionBuilder`, or called `put_to_platform` with `entropy = Some(..)`, with a hand-picked `document.id` that did not match `Document::generate_document_id_v0(...)` will now see `Error::InvalidArgument` instead of a silently signed transition. The README is the durable reference for external consumers and should call this out alongside the other create/replace/delete behavior changes.

Comment on lines +274 to +325
document_state_transition_entropy: Option<[u8; 32]>,
identity_public_key: &IdentityPublicKey,
token_payment_info: Option<TokenPaymentInfo>,
signer: &S,
settings: Option<PutSettings>,
) -> Result<StateTransition, Error> {
build_signed_document_create_or_replace_transition_legacy(
sdk,
document,
document_type,
document_state_transition_entropy,
identity_public_key,
token_payment_info,
signer,
settings,
)
.await
}

/// Private implementation backing the deprecated public legacy dispatcher.
///
/// Internal strict helpers route through this private entry point so
/// in-tree call sites do not trigger the public deprecation warning.
#[allow(clippy::too_many_arguments)]
async fn build_signed_document_create_or_replace_transition_legacy<S: Signer<IdentityPublicKey>>(
sdk: &Sdk,
document: &Document,
document_type: &DocumentType,
document_state_transition_entropy: Option<[u8; 32]>,
identity_public_key: &IdentityPublicKey,
token_payment_info: Option<TokenPaymentInfo>,
signer: &S,
settings: Option<PutSettings>,
) -> Result<StateTransition, Error> {
// Reject the always-invalid `Some(0)` revision before allocating any
// nonce. Strict create/replace intent validation is the job of the
// dedicated helpers below.
ensure_revision_nonzero(document.revision())?;

let owner_id = document.owner_id();
let contract_id = document_type.data_contract_id();
let new_identity_contract_nonce = sdk
.get_identity_contract_nonce(owner_id, contract_id, true, settings)
.await?;

let result = build_and_sign_create_or_replace_after_nonce(
sdk,
document,
document_type,
document_state_transition_entropy,
identity_public_key,
token_payment_info,
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: _legacy private indirection has only one caller — the deprecated public wrapper

build_signed_document_create_or_replace_transition_legacy is documented as existing so that internal strict helpers can route through it without tripping the public deprecation warning. In the tree, however, the only caller of _legacy is the public deprecated wrapper itself. The strict create/replace helpers added in this PR dispatch into build_and_sign_create_after_nonce / build_and_sign_replace_after_nonce instead. That leaves the indirection as dead architecture — the deprecated public function could inline the impl, and the justification on _legacy should be removed or replaced with the actual reason (e.g. a specific anticipated future caller).

source: ['claude']

Comment on lines 461 to +489
) -> Result<DocumentCreateResult, Error> {
let platform_version = self.version();
// Destructure so we can move builder-owned fields (notably the
// `StateTransitionCreationOptions`, which is not necessarily Clone)
// into the effective settings without an extra copy.
let DocumentCreateTransitionBuilder {
data_contract,
document_type_name,
document,
document_state_transition_entropy,
token_payment_info,
settings,
user_fee_increase,
state_transition_creation_options,
} = create_document_transition_builder;

let put_settings = create_document_transition_builder.settings;
// Preserve broadcast-time settings (request_settings, wait_timeout,
// identity_nonce_stale_time_s) by keeping the original builder
// settings around for the broadcast call. The strict helper gets
// an `effective` clone that overlays the builder-specific
// user_fee_increase / state_transition_creation_options fields.
let broadcast_settings = settings;
let mut effective_settings = settings.unwrap_or_default();
if let Some(ufi) = user_fee_increase {
effective_settings.user_fee_increase = Some(ufi);
}
if state_transition_creation_options.is_some() {
effective_settings.state_transition_creation_options =
state_transition_creation_options;
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Comment claims StateTransitionCreationOptions may not be Clone — but it is Copy

The destructure comment in Sdk::document_create (and the symmetric block in Sdk::document_replace at replace.rs:407-432) justifies destructuring with "notably the StateTransitionCreationOptions, which is not necessarily Clone." PutSettings is #[derive(Debug, Clone, Copy, Default)] and one of its fields is Option<StateTransitionCreationOptions>, which means StateTransitionCreationOptions is also Copy (verified: packages/rs-dpp/.../methods/mod.rs:50 declares #[derive(Debug, Clone, Copy, Default, Eq, PartialEq)] pub struct StateTransitionCreationOptions). The destructure itself is fine, but the stated rationale will mislead a future reader. Drop the parenthetical or replace it with the real reason (e.g., "so we can read the user_fee_increase / state_transition_creation_options overrides while reusing settings as the broadcast settings").

source: ['claude']

Comment on lines +112 to +137
@@ -98,12 +123,28 @@ impl DocumentCreateTransitionBuilder {
/// # Returns
///
/// * `Self` - The updated builder
pub fn with_settings(mut self, settings: PutSettings) -> Self {
pub fn with_settings(mut self, mut settings: PutSettings) -> Self {
if self.user_fee_increase.is_none() {
self.user_fee_increase = settings.user_fee_increase;
}
if self.state_transition_creation_options.is_none() {
self.state_transition_creation_options = settings.state_transition_creation_options;
}
// Strip the fee/creation-options fields from the stored settings so
// the dedicated builder fields are the sole source of truth at
// sign time. The remainder of `settings` flows through unchanged.
settings.user_fee_increase = None;
settings.state_transition_creation_options = None;
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Revision and id-entropy validators run twice on the happy sign() path

DocumentCreateTransitionBuilder::sign runs ensure_revision_for_create and ensure_document_id_matches_entropy before allocating a nonce, then calls self.sign_with_nonce(...) which runs the same two validators again. Functionally fine (pure validators) but it costs an extra document_type lookup, revision check, and id derivation on every successful sign call. Either drop the validators from sign_with_nonce and document the precondition (callers using the pre-allocated-nonce variant must validate themselves) or factor a private sign_with_nonce_unchecked and have sign call it after its own up-front validation. The same double-validation pattern exists in replace.rs.

source: ['claude']

Comment on lines +266 to +300
pub(crate) async fn rollback_identity_contract_nonce(
&self,
identity_id: Identifier,
contract_id: Identifier,
allocated_nonce: IdentityNonce,
) {
if allocated_nonce == 0 {
// Nothing to roll back; bumping never produces 0 (it starts at 1).
return;
}
let key = IdentityContractPair {
identity_id,
contract_id,
};
let mut guard = self.contract_nonces.lock().await;
if let Some(entry) = guard.peek_mut(&key) {
if entry.current_nonce == allocated_nonce {
entry.current_nonce = allocated_nonce - 1;
tracing::trace!(
identity_id = %identity_id,
contract_id = %contract_id,
allocated_nonce,
"rolled back identity-contract nonce after local pre-broadcast failure"
);
} else {
tracing::trace!(
identity_id = %identity_id,
contract_id = %contract_id,
allocated_nonce,
cached_nonce = entry.current_nonce,
"skipped identity-contract nonce rollback: cache moved past allocated nonce"
);
}
}
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Document why rollback_identity_contract_nonce does not also reset last_fetched_platform_nonce

The rollback decrements entry.current_nonce but intentionally leaves entry.last_fetched_platform_nonce and entry.last_fetch_timestamp untouched. This is correct — rollback is a local correction, not a re-sync, and touching the drift counter would mask real drift. But after a long-running session that has bumped the cache near MAX_MISSING_IDENTITY_REVISIONS from last_fetched_platform_nonce, a rollback can leave the very next allocation hitting the drift-triggered platform refetch in get_or_fetch_nonce even though the rollback effectively "freed" the slot. A one-line comment near the rollback explaining the intent will prevent a future reader from "fixing" it by also bumping last_fetched_platform_nonce.

source: ['claude']

Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

The PR adds well-structured two-phase prepare APIs with conditional nonce rollback and stricter intent validation. The most pressing concern is a JS unit test that asserts the old revision-0 error message and will fail under the new dedicated message. Other notable issues: a typed→generic error-conversion regression in mixed structure validation, partial WASM trust-boundary hardening (legacy documentCreate/documentReplace and inner paymentTokenContractId/identityKey/signer still go through __wbg_ptr extraction), and extract_prepare_document structurally rebuilding documents while hard-coding creator_id: None.

Reviewed commit: 343f888

🔴 1 blocking | 🟡 5 suggestion(s) | 💬 3 nitpick(s)

1 additional finding

💬 nitpick: Bincode encoding after the helper returns has no rollback path

packages/rs-sdk-ffi/src/document/delete.rs (lines 174-196)

build_signed_document_delete_transition consumes its internal rollback handle on success, then this FFI runs bincode::encode_to_vec(&state_transition, …). The comment correctly notes that encoding StateTransition into a Vec<u8> is effectively infallible today, so a failure here only leaves the local identity-contract nonce cache advanced by one until the next refresh — no consensus impact. However, the previous in-house sign+manual-rollback site protected against exactly this drift, and a future fallible Encode impl (e.g. tokens/shielded payloads adding length-prefix bounds) silently turns this into an observable spurious-nonce-rejection from the FFI consumer's perspective. Either return the allocated nonce alongside the transition so this site can call sdk.rollback_identity_contract_nonce(...) on serialization failure, or fold the bincode step into a sibling helper that drops the rollback handle only after a successful encode.

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/wasm-sdk/tests/unit/prepare-document.spec.ts`:
- [BLOCKING] lines 403-415: documentCreate revision-0 unit test asserts an error message the new code never emits
  This test passes `revision: 0` and expects the message to match `/documentReplace/`. `documentCreate` now runs `ensure_document_create_revision(...)` first, which for `Some(0)` returns the dedicated `"revision 0 is invalid for both create and replace"` message and deliberately omits the sibling-API name (see `ensure_document_create_revision` at packages/wasm-sdk/src/state_transitions/document.rs:1650 and the matching Rust test at :1745). The JS suite will fail the moment it runs, and it no longer covers the new revision-0 behavior introduced by this PR.

In `packages/rs-sdk/src/platform/transition/validation.rs`:
- [SUGGESTION] lines 49-68: Mixed structure-validation errors are downgraded from typed ConsensusError to ProtocolError::Generic
  When `validate_structure` returns a mix of `UnsupportedFeatureError` and a real failure, `map_validation_result` joins every error's `to_string()` into a single message wrapped in `ProtocolError::Generic`. The default `From<SimpleConsensusValidationResult> for Error` conversion would have preserved at least the first concrete `ConsensusError`, so Rust/WASM/FFI callers could pattern-match on the variant. With the new code the entire mixed case becomes an opaque string, which forces text matching — a real API regression now that `Error::InvalidArgument` is plumbed through elsewhere specifically to avoid that pattern. Preserve the typed errors (e.g. either keep the first non-unsupported `ConsensusError` as the primary `Error` and attach the rendered tail as context, or wrap the filtered list in a fresh `SimpleConsensusValidationResult` and reuse the existing `From` conversion).

In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 309-310: Legacy documentCreate/documentReplace still extract `document` via pointer-trusting path
  `DocumentWasm::try_from_options(&options, "document")` resolves to `impl_try_from_js_value!(DocumentWasm, "Document")` (packages/wasm-dpp2/src/data_contract/document/model.rs:769) → `generic_of_js_val` (packages/wasm-dpp2/src/utils.rs:114–141), which checks the spoofable `__type` string and then performs `unsafe { T::ref_from_abi(__wbg_ptr) }` on a caller-controlled `u32`. This PR's `extract_prepare_document`/`extract_delete_identifiers` exist precisely to avoid that path on untrusted JS, but the same `try_from_options` call remains on `documentCreate` (this line), `documentReplace` (:452), `documentTransfer` (:1058), `documentPurchase` (:1188), and `documentSetPrice` (:1308). A forged `{ __type: "Document", __wbg_ptr: <int> }` can still trap the WASM module or alias an unrelated live `Document`. Not introduced by this PR, but the new hardening sets the precedent — applying `extract_prepare_document` to the remaining one-shot entry points (or documenting why those callers are considered trusted) keeps the boundary consistent.
- [SUGGESTION] lines 174-196: tokenPaymentInfo hardening leaks back into the pointer path via paymentTokenContractId
  `try_from_options_optional_token_payment_info` carefully avoids `TokenPaymentInfoWasm::try_from(&JsValue)` and instead copies public fields into a fresh options bag, then calls `TokenPaymentInfoWasm::constructor(...)`. That constructor parses `paymentTokenContractId` with `try_from_options_optional::<IdentifierWasm>` (packages/wasm-dpp2/src/state_transitions/batch/token_payment_info.rs:96-97), which goes through `IdentifierWasm::TryFrom<JsValue>` → `to_wasm::<IdentifierWasm>("Identifier")` → `generic_of_js_val` (packages/wasm-dpp2/src/identifier.rs:114-125, packages/wasm-dpp2/src/utils.rs:114-141). The first branch of that converter checks `__type === "Identifier"` and then dereferences `__wbg_ptr`. A forged `TokenPaymentInfo` with `paymentTokenContractId: { __type: "Identifier", __wbg_ptr: <int> }` therefore still smuggles an arbitrary pointer through, defeating the wrapper-level hardening. Read `paymentTokenContractId` via `extract_identifier_property` (which is already in this file and only uses string/byte/`toBytes/toString/toJSON` paths) before forwarding it to the constructor.
- [SUGGESTION] lines 733-737: Prepare APIs still trust `__wbg_ptr` for identityKey and signer
  `prepare_document_create` (lines 733-737) and the same pair in `prepare_document_replace` (:836-840) and `prepare_document_delete` (:939-943) harden `document` but still call `IdentityPublicKeyWasm::try_from_options` and `IdentitySignerWasm::try_from_options`. Both go through `impl_try_from_js_value!` → `IntoWasm::to_wasm` → `generic_of_js_val`, which dereferences `__wbg_ptr` after only verifying a spoofable `__type`. The threat model that motivates `extract_prepare_document` applies at least as strongly to `signer` (holds private-key material) and `identityKey`. Either harden the two inputs analogously (e.g. require real class instances detected via a non-pointer discriminator) or add a module-level note explicitly carving these out as trusted-producer-only and explaining why structural extraction is not viable for opaque signer state.
- [SUGGESTION] lines 1588-1614: extract_prepare_document silently zeros `creator_id` and drops fields not enumerated
  The helper rebuilds `Document::V0` from enumerated public getters but hard-codes `creator_id: None` (line 1605) because wasm-dpp2's `Document` does not expose a getter. For plain-object input the caller MAY pass `creatorId`, and it is silently dropped; for class-instance input the value is forced to `None` regardless of the original. Harmless for v0 create transitions today (creator_id is not consumed by `new_document_creation_transition_from_document` / `new_document_replacement_transition_from_document`), but the boundary-hardening rationale ("read every field via the public surface") means any future field added to `DocumentV0` or to wasm-dpp2's `Document` getters silently disappears here with no compile error. Either add a `creatorId` getter on wasm-dpp2's `Document` and read it, or destructure `DocumentV0` with named fields (no `..`) so adding a new field forces this code to be revisited; at minimum document the dropped fields on the TS `PrepareDocument*Options.document` interface.

Comment on lines +403 to +415
it('documentCreate rejects a document with revision 0', async () => {
const { signer, identityKey } = buildSigner();
const document = buildDocument({ revision: 0 });

try {
await client.documentCreate({ document, identityKey, signer });
expect.fail('expected documentCreate to reject revision 0');
} catch (e) {
expect(e).to.be.instanceOf(sdk.WasmSdkError);
expect(e.name).to.equal('InvalidArgument');
expect(e.message).to.match(/revision/i);
expect(e.message).to.match(/documentReplace/);
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: documentCreate revision-0 unit test asserts an error message the new code never emits

This test passes revision: 0 and expects the message to match /documentReplace/. documentCreate now runs ensure_document_create_revision(...) first, which for Some(0) returns the dedicated "revision 0 is invalid for both create and replace" message and deliberately omits the sibling-API name (see ensure_document_create_revision at packages/wasm-sdk/src/state_transitions/document.rs:1650 and the matching Rust test at :1745). The JS suite will fail the moment it runs, and it no longer covers the new revision-0 behavior introduced by this PR.

💡 Suggested change
Suggested change
it('documentCreate rejects a document with revision 0', async () => {
const { signer, identityKey } = buildSigner();
const document = buildDocument({ revision: 0 });
try {
await client.documentCreate({ document, identityKey, signer });
expect.fail('expected documentCreate to reject revision 0');
} catch (e) {
expect(e).to.be.instanceOf(sdk.WasmSdkError);
expect(e.name).to.equal('InvalidArgument');
expect(e.message).to.match(/revision/i);
expect(e.message).to.match(/documentReplace/);
}
} catch (e) {
expect(e).to.be.instanceOf(sdk.WasmSdkError);
expect(e.name).to.equal('InvalidArgument');
expect(e.message).to.match(/revision 0 is invalid for both create and replace/);
expect(e.message).to.not.match(/documentReplace/);
expect(e.message).to.not.match(/documentCreate/);
}

source: ['codex']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/wasm-sdk/tests/unit/prepare-document.spec.ts`:
- [BLOCKING] lines 403-415: documentCreate revision-0 unit test asserts an error message the new code never emits
  This test passes `revision: 0` and expects the message to match `/documentReplace/`. `documentCreate` now runs `ensure_document_create_revision(...)` first, which for `Some(0)` returns the dedicated `"revision 0 is invalid for both create and replace"` message and deliberately omits the sibling-API name (see `ensure_document_create_revision` at packages/wasm-sdk/src/state_transitions/document.rs:1650 and the matching Rust test at :1745). The JS suite will fail the moment it runs, and it no longer covers the new revision-0 behavior introduced by this PR.

Comment on lines +49 to +68
// Mixed `UnsupportedFeatureError` + real-error case. The default
// `From<SimpleConsensusValidationResult> for Error` conversion only
// keeps the first error and would therefore silently lose every
// remaining entry (including the unsupported ones, which in this
// shape are *not* "not implemented" placeholders but legitimate
// "feature unsupported on this platform version" rejections).
//
// Render every error into the message so callers see the full
// diagnostic set, then wrap as a `ProtocolError::Generic` so the
// returned `Error` still surfaces as a protocol-layer failure.
let rendered = result
.errors
.iter()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join("; ");
Err(Error::Protocol(ProtocolError::Generic(format!(
"state transition structure validation failed: {rendered}"
))))
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Mixed structure-validation errors are downgraded from typed ConsensusError to ProtocolError::Generic

When validate_structure returns a mix of UnsupportedFeatureError and a real failure, map_validation_result joins every error's to_string() into a single message wrapped in ProtocolError::Generic. The default From<SimpleConsensusValidationResult> for Error conversion would have preserved at least the first concrete ConsensusError, so Rust/WASM/FFI callers could pattern-match on the variant. With the new code the entire mixed case becomes an opaque string, which forces text matching — a real API regression now that Error::InvalidArgument is plumbed through elsewhere specifically to avoid that pattern. Preserve the typed errors (e.g. either keep the first non-unsupported ConsensusError as the primary Error and attach the rendered tail as context, or wrap the filtered list in a fresh SimpleConsensusValidationResult and reuse the existing From conversion).

source: ['claude', 'codex']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/transition/validation.rs`:
- [SUGGESTION] lines 49-68: Mixed structure-validation errors are downgraded from typed ConsensusError to ProtocolError::Generic
  When `validate_structure` returns a mix of `UnsupportedFeatureError` and a real failure, `map_validation_result` joins every error's `to_string()` into a single message wrapped in `ProtocolError::Generic`. The default `From<SimpleConsensusValidationResult> for Error` conversion would have preserved at least the first concrete `ConsensusError`, so Rust/WASM/FFI callers could pattern-match on the variant. With the new code the entire mixed case becomes an opaque string, which forces text matching — a real API regression now that `Error::InvalidArgument` is plumbed through elsewhere specifically to avoid that pattern. Preserve the typed errors (e.g. either keep the first non-unsupported `ConsensusError` as the primary `Error` and attach the rendered tail as context, or wrap the filtered list in a fresh `SimpleConsensusValidationResult` and reuse the existing `From` conversion).

Comment on lines 309 to 310
let document_wasm = DocumentWasm::try_from_options(&options, "document")?;
let document: Document = document_wasm.clone().into();
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Legacy documentCreate/documentReplace still extract document via pointer-trusting path

DocumentWasm::try_from_options(&options, "document") resolves to impl_try_from_js_value!(DocumentWasm, "Document") (packages/wasm-dpp2/src/data_contract/document/model.rs:769) → generic_of_js_val (packages/wasm-dpp2/src/utils.rs:114–141), which checks the spoofable __type string and then performs unsafe { T::ref_from_abi(__wbg_ptr) } on a caller-controlled u32. This PR's extract_prepare_document/extract_delete_identifiers exist precisely to avoid that path on untrusted JS, but the same try_from_options call remains on documentCreate (this line), documentReplace (:452), documentTransfer (:1058), documentPurchase (:1188), and documentSetPrice (:1308). A forged { __type: "Document", __wbg_ptr: <int> } can still trap the WASM module or alias an unrelated live Document. Not introduced by this PR, but the new hardening sets the precedent — applying extract_prepare_document to the remaining one-shot entry points (or documenting why those callers are considered trusted) keeps the boundary consistent.

source: ['codex']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 309-310: Legacy documentCreate/documentReplace still extract `document` via pointer-trusting path
  `DocumentWasm::try_from_options(&options, "document")` resolves to `impl_try_from_js_value!(DocumentWasm, "Document")` (packages/wasm-dpp2/src/data_contract/document/model.rs:769) → `generic_of_js_val` (packages/wasm-dpp2/src/utils.rs:114–141), which checks the spoofable `__type` string and then performs `unsafe { T::ref_from_abi(__wbg_ptr) }` on a caller-controlled `u32`. This PR's `extract_prepare_document`/`extract_delete_identifiers` exist precisely to avoid that path on untrusted JS, but the same `try_from_options` call remains on `documentCreate` (this line), `documentReplace` (:452), `documentTransfer` (:1058), `documentPurchase` (:1188), and `documentSetPrice` (:1308). A forged `{ __type: "Document", __wbg_ptr: <int> }` can still trap the WASM module or alias an unrelated live `Document`. Not introduced by this PR, but the new hardening sets the precedent — applying `extract_prepare_document` to the remaining one-shot entry points (or documenting why those callers are considered trusted) keeps the boundary consistent.

Comment on lines 174 to 196
let token_payment_info = match class_type.as_str() {
"TokenPaymentInfo" => TokenPaymentInfoWasm::constructor(
token_payment_info_options_from_public_fields(&token_payment_info_value)?,
)
.map_err(|err| WasmSdkError::invalid_argument(err.to_string()))?,
// Plain object path: no `__type` getter is set up, so
// `get_class_type` returns `Ok("")` (empty string default in
// `JsValue::as_string().unwrap_or_default()`). Treat the empty
// string the same as "no class marker present".
"" => TokenPaymentInfoWasm::constructor(
token_payment_info_value.unchecked_into::<TokenPaymentInfoOptionsJs>(),
)
.map_err(|err| WasmSdkError::invalid_argument(err.to_string()))?,
other => {
return Err(WasmSdkError::invalid_argument(format!(
"tokenPaymentInfo must be a plain DocumentTokenPaymentInfo options object \
or a TokenPaymentInfo instance, got class '{other}'"
)));
}
};

Ok(Some(token_payment_info.into()))
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: tokenPaymentInfo hardening leaks back into the pointer path via paymentTokenContractId

try_from_options_optional_token_payment_info carefully avoids TokenPaymentInfoWasm::try_from(&JsValue) and instead copies public fields into a fresh options bag, then calls TokenPaymentInfoWasm::constructor(...). That constructor parses paymentTokenContractId with try_from_options_optional::<IdentifierWasm> (packages/wasm-dpp2/src/state_transitions/batch/token_payment_info.rs:96-97), which goes through IdentifierWasm::TryFrom<JsValue>to_wasm::<IdentifierWasm>("Identifier")generic_of_js_val (packages/wasm-dpp2/src/identifier.rs:114-125, packages/wasm-dpp2/src/utils.rs:114-141). The first branch of that converter checks __type === "Identifier" and then dereferences __wbg_ptr. A forged TokenPaymentInfo with paymentTokenContractId: { __type: "Identifier", __wbg_ptr: <int> } therefore still smuggles an arbitrary pointer through, defeating the wrapper-level hardening. Read paymentTokenContractId via extract_identifier_property (which is already in this file and only uses string/byte/toBytes/toString/toJSON paths) before forwarding it to the constructor.

source: ['codex']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 174-196: tokenPaymentInfo hardening leaks back into the pointer path via paymentTokenContractId
  `try_from_options_optional_token_payment_info` carefully avoids `TokenPaymentInfoWasm::try_from(&JsValue)` and instead copies public fields into a fresh options bag, then calls `TokenPaymentInfoWasm::constructor(...)`. That constructor parses `paymentTokenContractId` with `try_from_options_optional::<IdentifierWasm>` (packages/wasm-dpp2/src/state_transitions/batch/token_payment_info.rs:96-97), which goes through `IdentifierWasm::TryFrom<JsValue>` → `to_wasm::<IdentifierWasm>("Identifier")` → `generic_of_js_val` (packages/wasm-dpp2/src/identifier.rs:114-125, packages/wasm-dpp2/src/utils.rs:114-141). The first branch of that converter checks `__type === "Identifier"` and then dereferences `__wbg_ptr`. A forged `TokenPaymentInfo` with `paymentTokenContractId: { __type: "Identifier", __wbg_ptr: <int> }` therefore still smuggles an arbitrary pointer through, defeating the wrapper-level hardening. Read `paymentTokenContractId` via `extract_identifier_property` (which is already in this file and only uses string/byte/`toBytes/toString/toJSON` paths) before forwarding it to the constructor.

Comment on lines +733 to +737
let identity_key_wasm = IdentityPublicKeyWasm::try_from_options(&options, "identityKey")?;
let identity_key: IdentityPublicKey = identity_key_wasm.into();

// Extract signer from options
let signer = IdentitySignerWasm::try_from_options(&options, "signer")?;
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Prepare APIs still trust __wbg_ptr for identityKey and signer

prepare_document_create (lines 733-737) and the same pair in prepare_document_replace (:836-840) and prepare_document_delete (:939-943) harden document but still call IdentityPublicKeyWasm::try_from_options and IdentitySignerWasm::try_from_options. Both go through impl_try_from_js_value!IntoWasm::to_wasmgeneric_of_js_val, which dereferences __wbg_ptr after only verifying a spoofable __type. The threat model that motivates extract_prepare_document applies at least as strongly to signer (holds private-key material) and identityKey. Either harden the two inputs analogously (e.g. require real class instances detected via a non-pointer discriminator) or add a module-level note explicitly carving these out as trusted-producer-only and explaining why structural extraction is not viable for opaque signer state.

source: ['claude', 'codex']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 733-737: Prepare APIs still trust `__wbg_ptr` for identityKey and signer
  `prepare_document_create` (lines 733-737) and the same pair in `prepare_document_replace` (:836-840) and `prepare_document_delete` (:939-943) harden `document` but still call `IdentityPublicKeyWasm::try_from_options` and `IdentitySignerWasm::try_from_options`. Both go through `impl_try_from_js_value!` → `IntoWasm::to_wasm` → `generic_of_js_val`, which dereferences `__wbg_ptr` after only verifying a spoofable `__type`. The threat model that motivates `extract_prepare_document` applies at least as strongly to `signer` (holds private-key material) and `identityKey`. Either harden the two inputs analogously (e.g. require real class instances detected via a non-pointer discriminator) or add a module-level note explicitly carving these out as trusted-producer-only and explaining why structural extraction is not viable for opaque signer state.

Comment on lines +1588 to +1614
let document = Document::V0(DocumentV0 {
id,
owner_id,
properties,
revision,
created_at,
updated_at,
transferred_at,
created_at_block_height,
updated_at_block_height,
transferred_at_block_height,
created_at_core_block_height,
updated_at_core_block_height,
transferred_at_core_block_height,
// `creator_id` is not part of the public `Document` getter surface
// exposed by wasm-dpp2, so we cannot copy it structurally. Leaving
// it `None` matches the `DocumentWasm` constructor's own default.
creator_id: None,
});

Ok(DocumentWasm::new(
document,
contract_id,
document_type_name,
entropy,
))
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: extract_prepare_document silently zeros creator_id and drops fields not enumerated

The helper rebuilds Document::V0 from enumerated public getters but hard-codes creator_id: None (line 1605) because wasm-dpp2's Document does not expose a getter. For plain-object input the caller MAY pass creatorId, and it is silently dropped; for class-instance input the value is forced to None regardless of the original. Harmless for v0 create transitions today (creator_id is not consumed by new_document_creation_transition_from_document / new_document_replacement_transition_from_document), but the boundary-hardening rationale ("read every field via the public surface") means any future field added to DocumentV0 or to wasm-dpp2's Document getters silently disappears here with no compile error. Either add a creatorId getter on wasm-dpp2's Document and read it, or destructure DocumentV0 with named fields (no ..) so adding a new field forces this code to be revisited; at minimum document the dropped fields on the TS PrepareDocument*Options.document interface.

source: ['claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 1588-1614: extract_prepare_document silently zeros `creator_id` and drops fields not enumerated
  The helper rebuilds `Document::V0` from enumerated public getters but hard-codes `creator_id: None` (line 1605) because wasm-dpp2's `Document` does not expose a getter. For plain-object input the caller MAY pass `creatorId`, and it is silently dropped; for class-instance input the value is forced to `None` regardless of the original. Harmless for v0 create transitions today (creator_id is not consumed by `new_document_creation_transition_from_document` / `new_document_replacement_transition_from_document`), but the boundary-hardening rationale ("read every field via the public surface") means any future field added to `DocumentV0` or to wasm-dpp2's `Document` getters silently disappears here with no compile error. Either add a `creatorId` getter on wasm-dpp2's `Document` and read it, or destructure `DocumentV0` with named fields (no `..`) so adding a new field forces this code to be revisited; at minimum document the dropped fields on the TS `PrepareDocument*Options.document` interface.

Comment on lines +455 to +459
// Destructure so we can move builder-owned fields (notably the
// `StateTransitionCreationOptions`, which is not necessarily Clone)
// into the effective settings without an extra copy.
let DocumentCreateTransitionBuilder {
data_contract,
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Destructure rationale comment is incorrect: StateTransitionCreationOptions is Copy

The comment justifies destructuring with notably the StateTransitionCreationOptions, which is not necessarily Clone, but PutSettings (packages/rs-sdk/src/platform/transition/put_settings.rs:8) derives Copy and StateTransitionCreationOptions (packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/mod.rs:50) derives Copy, Clone. The destructure does not avoid any clones; it's equivalent to plain field reads. The same comment appears in the analogous Sdk::document_replace entry in replace.rs. Update both comments or drop them — the destructure is fine for readability, just not for the stated reason.

source: ['claude']

Comment on lines +57 to +59
fn is_document_replace_revision(revision: Option<u64>) -> bool {
revision.is_some_and(|rev| rev > INITIAL_REVISION)
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: is_document_replace_revision is only reachable through the deprecated legacy dispatcher

After the strict ensure_revision_for_create / ensure_revision_for_replace guards landed, the sole non-test caller of is_document_replace_revision is build_and_sign_create_or_replace_after_nonce (line 619), which itself is only used by build_signed_document_create_or_replace_transition_legacy (line 309), backing the #[deprecated] public entry point at line 281. When the deprecated legacy dispatcher is removed, this function becomes dead code with no compile-time signal. Mark it #[deprecated] alongside the legacy dispatcher (or inline it into build_and_sign_create_or_replace_after_nonce) so retiring the legacy entry point also retires this helper in one motion.

source: ['claude']

@thepastaclaw thepastaclaw force-pushed the feat/sdk-prepare-document-apis branch from 343f888 to 18a7df1 Compare May 13, 2026 19:50
Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Verified findings across the PR's two-phase document prepare APIs, strict create/replace helpers, and structural JS extraction. One blocking issue is real: the new structural extraction in extract_prepare_document routes properties through JSON normalization that erases Identifier/Bytes/BigInt typing, with no test coverage for byte-typed properties — and for one-shot documentCreate/documentReplace this is a regression vs the previous pointer-deref path that returned the in-Rust typed BTreeMap for fetched documents. Codex's blocking finding on identityKey/signer __wbg_ptr trust is dropped as an explicitly documented and project-wide accepted carve-out. Several smaller redundancies, an API-surface widening, deprecation hygiene, and a PR-description-vs-code mismatch round out the remaining suggestions/nitpicks.

Reviewed commit: 18a7df1

🔴 1 blocking | 🟡 2 suggestion(s) | 💬 6 nitpick(s)

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [BLOCKING] lines 1695-1697: extract_prepare_document drops Identifier/Bytes/BigInt typing on real Document instances
  `extract_prepare_document` now reads `document.properties` via `with_serde_to_platform_value_map`, which goes through `js_value_to_json` → `normalize_js_value_for_json` (`packages/wasm-dpp2/src/serialization/conversions.rs:94`). That path converts `Uint8Array` → plain JS number arrays and `BigInt` → strings before round-tripping through `serde_wasm_bindgen` into `platform_value::Value`.

When the JS caller passes a real `Document` class instance (e.g. fetched from Platform and re-presented), the `properties` getter in `packages/wasm-dpp2/src/data_contract/document/model.rs:279` serializes typed properties via `platform_value_to_object` — `Identifier` becomes `Uint8Array`, `Bytes*` becomes `Uint8Array`, `U64` becomes `BigInt`. After the new normalization, those reconstruct as `Value::Array(Vec<Value::U64>)` and `Value::Text(String)` rather than `Value::Identifier([u8;32])` / `Value::Bytes(...)` / `Value::U64(_)`.

The previous one-shot `documentCreate`/`documentReplace` path used `DocumentWasm::try_from_options(&options, "document")?` (verified at base commit `c7cb8e6f5f`), which dereferenced the wasm-bindgen handle and returned the in-Rust typed `BTreeMap` intact. The new extractor is therefore a genuine behavior change for fetched documents and for any caller passing a `Document` instance whose schema has byte- or bigint-typed fields (DPNS / DashPay identifiers, contested resource IDs, etc.).

This change is now in the path for `documentCreate`, `documentReplace`, `documentTransfer`, `documentPurchase`, `documentSetPrice`, `prepareDocumentCreate`, and `prepareDocumentReplace`. The new unit tests in `packages/wasm-sdk/tests/unit/prepare-document.spec.ts` only exercise `{ message: 'hello' }`-style string properties, so the regression is not caught by CI.

Recommended fixes: (1) restore the trusted `DocumentWasm::try_from(&JsValue)` path when the input is a real `Document` instance (detected via `get_class_type(...) == "Document"`, mirroring the existing pattern in `extract_delete_identifiers` at line 1641+), and apply structural extraction only to plain-object inputs; or (2) route the structural path through a deserializer that preserves identifier/bytes typing rather than human-readable JSON. Either fix should be paired with unit tests that exercise an Identifier-typed property end-to-end.

In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 136-147: derive_document_id_v0 hard-pinned to v0 instead of using platform-version dispatch
  `derive_document_id_v0` and every call site (`ensure_document_id_matches_entropy`, the legacy entropy fallback at line 194, the wasm-sdk fast-path `ensure_document_id_matches_entropy_fast`) hard-code `Document::generate_document_id_v0` on a consensus-critical path: this helper pre-rejects user transitions with `Error::InvalidArgument` before nonce allocation, and Platform enforces the same invariant.

The TODO comment (lines 126-135) acknowledges that any v1 derivation requires coordinated changes at every call site — that is exactly what platform-version dispatch (`platform_version.document_versions.derive_document_id`) is meant to centralize. A single `match version { 0 => ..., v => Err(UnknownVersionMismatch) }` here would catch the migration at compile/runtime instead of via a grep-and-pray TODO, and aligns with the project's own convention that versioned dispatch is mandatory for consensus-critical code.

In `packages/rs-sdk/src/platform/transition/put_settings.rs`:
- [SUGGESTION] lines 51-65: Second with_settings() call silently drops its fee_increase / state_transition_creation_options
  `PutSettings::split_dedicated_fields` writes `user_fee_increase` / `state_transition_creation_options` into the dedicated builder fields **only if** those fields are currently `None`. The first `with_settings(s1)` populates them from `s1` and clears them out of the stored settings. A subsequent `with_settings(s2)` sees the dedicated fields are `Some(_)`, leaves them alone, then clears `s2.user_fee_increase` / `s2.state_transition_creation_options` — silently dropping any new fee/options carried by `s2` while overwriting the rest of the settings.

This is contrary to the natural reading of "with_settings replaces the settings", and the doc-comments on the three document builders only describe the explicit-setter-vs-settings precedence, not this first-wins ordering for fee/options. Either document the edge case explicitly on every `with_settings` impl, or make the second `with_settings` call overwrite the dedicated fields (the second call is itself an explicit setter for those fields).

Comment on lines +1695 to +1697
let properties = try_from_options_with(&document_js, "properties", |v| {
v.with_serde_to_platform_value_map()
})?;
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: extract_prepare_document drops Identifier/Bytes/BigInt typing on real Document instances

extract_prepare_document now reads document.properties via with_serde_to_platform_value_map, which goes through js_value_to_jsonnormalize_js_value_for_json (packages/wasm-dpp2/src/serialization/conversions.rs:94). That path converts Uint8Array → plain JS number arrays and BigInt → strings before round-tripping through serde_wasm_bindgen into platform_value::Value.

When the JS caller passes a real Document class instance (e.g. fetched from Platform and re-presented), the properties getter in packages/wasm-dpp2/src/data_contract/document/model.rs:279 serializes typed properties via platform_value_to_objectIdentifier becomes Uint8Array, Bytes* becomes Uint8Array, U64 becomes BigInt. After the new normalization, those reconstruct as Value::Array(Vec<Value::U64>) and Value::Text(String) rather than Value::Identifier([u8;32]) / Value::Bytes(...) / Value::U64(_).

The previous one-shot documentCreate/documentReplace path used DocumentWasm::try_from_options(&options, "document")? (verified at base commit c7cb8e6f5f), which dereferenced the wasm-bindgen handle and returned the in-Rust typed BTreeMap intact. The new extractor is therefore a genuine behavior change for fetched documents and for any caller passing a Document instance whose schema has byte- or bigint-typed fields (DPNS / DashPay identifiers, contested resource IDs, etc.).

This change is now in the path for documentCreate, documentReplace, documentTransfer, documentPurchase, documentSetPrice, prepareDocumentCreate, and prepareDocumentReplace. The new unit tests in packages/wasm-sdk/tests/unit/prepare-document.spec.ts only exercise { message: 'hello' }-style string properties, so the regression is not caught by CI.

Recommended fixes: (1) restore the trusted DocumentWasm::try_from(&JsValue) path when the input is a real Document instance (detected via get_class_type(...) == "Document", mirroring the existing pattern in extract_delete_identifiers at line 1641+), and apply structural extraction only to plain-object inputs; or (2) route the structural path through a deserializer that preserves identifier/bytes typing rather than human-readable JSON. Either fix should be paired with unit tests that exercise an Identifier-typed property end-to-end.

source: ['claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [BLOCKING] lines 1695-1697: extract_prepare_document drops Identifier/Bytes/BigInt typing on real Document instances
  `extract_prepare_document` now reads `document.properties` via `with_serde_to_platform_value_map`, which goes through `js_value_to_json` → `normalize_js_value_for_json` (`packages/wasm-dpp2/src/serialization/conversions.rs:94`). That path converts `Uint8Array` → plain JS number arrays and `BigInt` → strings before round-tripping through `serde_wasm_bindgen` into `platform_value::Value`.

When the JS caller passes a real `Document` class instance (e.g. fetched from Platform and re-presented), the `properties` getter in `packages/wasm-dpp2/src/data_contract/document/model.rs:279` serializes typed properties via `platform_value_to_object` — `Identifier` becomes `Uint8Array`, `Bytes*` becomes `Uint8Array`, `U64` becomes `BigInt`. After the new normalization, those reconstruct as `Value::Array(Vec<Value::U64>)` and `Value::Text(String)` rather than `Value::Identifier([u8;32])` / `Value::Bytes(...)` / `Value::U64(_)`.

The previous one-shot `documentCreate`/`documentReplace` path used `DocumentWasm::try_from_options(&options, "document")?` (verified at base commit `c7cb8e6f5f`), which dereferenced the wasm-bindgen handle and returned the in-Rust typed `BTreeMap` intact. The new extractor is therefore a genuine behavior change for fetched documents and for any caller passing a `Document` instance whose schema has byte- or bigint-typed fields (DPNS / DashPay identifiers, contested resource IDs, etc.).

This change is now in the path for `documentCreate`, `documentReplace`, `documentTransfer`, `documentPurchase`, `documentSetPrice`, `prepareDocumentCreate`, and `prepareDocumentReplace`. The new unit tests in `packages/wasm-sdk/tests/unit/prepare-document.spec.ts` only exercise `{ message: 'hello' }`-style string properties, so the regression is not caught by CI.

Recommended fixes: (1) restore the trusted `DocumentWasm::try_from(&JsValue)` path when the input is a real `Document` instance (detected via `get_class_type(...) == "Document"`, mirroring the existing pattern in `extract_delete_identifiers` at line 1641+), and apply structural extraction only to plain-object inputs; or (2) route the structural path through a deserializer that preserves identifier/bytes typing rather than human-readable JSON. Either fix should be paired with unit tests that exercise an Identifier-typed property end-to-end.

Comment on lines +136 to +147
fn derive_document_id_v0(
document_type: DocumentTypeRef<'_>,
owner_id: &Identifier,
entropy: &[u8; 32],
) -> Identifier {
Document::generate_document_id_v0(
&document_type.data_contract_id(),
owner_id,
document_type.name(),
entropy.as_slice(),
)
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: derive_document_id_v0 hard-pinned to v0 instead of using platform-version dispatch

derive_document_id_v0 and every call site (ensure_document_id_matches_entropy, the legacy entropy fallback at line 194, the wasm-sdk fast-path ensure_document_id_matches_entropy_fast) hard-code Document::generate_document_id_v0 on a consensus-critical path: this helper pre-rejects user transitions with Error::InvalidArgument before nonce allocation, and Platform enforces the same invariant.

The TODO comment (lines 126-135) acknowledges that any v1 derivation requires coordinated changes at every call site — that is exactly what platform-version dispatch (platform_version.document_versions.derive_document_id) is meant to centralize. A single match version { 0 => ..., v => Err(UnknownVersionMismatch) } here would catch the migration at compile/runtime instead of via a grep-and-pray TODO, and aligns with the project's own convention that versioned dispatch is mandatory for consensus-critical code.

source: ['claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 136-147: derive_document_id_v0 hard-pinned to v0 instead of using platform-version dispatch
  `derive_document_id_v0` and every call site (`ensure_document_id_matches_entropy`, the legacy entropy fallback at line 194, the wasm-sdk fast-path `ensure_document_id_matches_entropy_fast`) hard-code `Document::generate_document_id_v0` on a consensus-critical path: this helper pre-rejects user transitions with `Error::InvalidArgument` before nonce allocation, and Platform enforces the same invariant.

The TODO comment (lines 126-135) acknowledges that any v1 derivation requires coordinated changes at every call site — that is exactly what platform-version dispatch (`platform_version.document_versions.derive_document_id`) is meant to centralize. A single `match version { 0 => ..., v => Err(UnknownVersionMismatch) }` here would catch the migration at compile/runtime instead of via a grep-and-pray TODO, and aligns with the project's own convention that versioned dispatch is mandatory for consensus-critical code.

Comment on lines +51 to +65
pub fn split_dedicated_fields(
mut self,
dedicated_user_fee_increase: &mut Option<UserFeeIncrease>,
dedicated_state_transition_creation_options: &mut Option<StateTransitionCreationOptions>,
) -> Self {
if dedicated_user_fee_increase.is_none() {
*dedicated_user_fee_increase = self.user_fee_increase;
}
if dedicated_state_transition_creation_options.is_none() {
*dedicated_state_transition_creation_options = self.state_transition_creation_options;
}
self.user_fee_increase = None;
self.state_transition_creation_options = None;
self
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Second with_settings() call silently drops its fee_increase / state_transition_creation_options

PutSettings::split_dedicated_fields writes user_fee_increase / state_transition_creation_options into the dedicated builder fields only if those fields are currently None. The first with_settings(s1) populates them from s1 and clears them out of the stored settings. A subsequent with_settings(s2) sees the dedicated fields are Some(_), leaves them alone, then clears s2.user_fee_increase / s2.state_transition_creation_options — silently dropping any new fee/options carried by s2 while overwriting the rest of the settings.

This is contrary to the natural reading of "with_settings replaces the settings", and the doc-comments on the three document builders only describe the explicit-setter-vs-settings precedence, not this first-wins ordering for fee/options. Either document the edge case explicitly on every with_settings impl, or make the second with_settings call overwrite the dedicated fields (the second call is itself an explicit setter for those fields).

source: ['claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/transition/put_settings.rs`:
- [SUGGESTION] lines 51-65: Second with_settings() call silently drops its fee_increase / state_transition_creation_options
  `PutSettings::split_dedicated_fields` writes `user_fee_increase` / `state_transition_creation_options` into the dedicated builder fields **only if** those fields are currently `None`. The first `with_settings(s1)` populates them from `s1` and clears them out of the stored settings. A subsequent `with_settings(s2)` sees the dedicated fields are `Some(_)`, leaves them alone, then clears `s2.user_fee_increase` / `s2.state_transition_creation_options` — silently dropping any new fee/options carried by `s2` while overwriting the rest of the settings.

This is contrary to the natural reading of "with_settings replaces the settings", and the doc-comments on the three document builders only describe the explicit-setter-vs-settings precedence, not this first-wins ordering for fee/options. Either document the edge case explicitly on every `with_settings` impl, or make the second `with_settings` call overwrite the dedicated fields (the second call is itself an explicit setter for those fields).

Comment on lines +185 to +253
{
let document_type = self
.data_contract
.document_type_for_name(&self.document_type_name)
.map_err(|e| Error::Protocol(e.into()))?;
ensure_revision_for_create(self.document.revision())?;
ensure_document_id_matches_entropy(
&self.document,
document_type,
&self.document_state_transition_entropy,
)?;
}

let owner_id = self.document.owner_id();
let contract_id = self.data_contract.id();
let identity_contract_nonce = sdk
.get_identity_contract_nonce(
self.document.owner_id(),
self.data_contract.id(),
true,
self.settings,
)
.get_identity_contract_nonce(owner_id, contract_id, true, self.settings)
.await?;

match self
.sign_with_nonce(
identity_contract_nonce,
identity_public_key,
signer,
platform_version,
)
.await
{
Ok(transition) => Ok(transition),
Err(err) => {
sdk.rollback_identity_contract_nonce(
owner_id,
contract_id,
identity_contract_nonce,
)
.await;
Err(err)
}
}
}

/// Signs the document create transition using a pre-allocated
/// identity-contract nonce.
///
/// This variant lets the caller separate nonce allocation from signing so
/// pre-broadcast failures can be rolled back by calling
/// [`Sdk::rollback_identity_contract_nonce`](crate::Sdk::rollback_identity_contract_nonce)
/// with the same `identity_contract_nonce`. The caller is responsible for
/// having obtained the nonce via
/// [`Sdk::get_identity_contract_nonce`](crate::Sdk::get_identity_contract_nonce)
/// with `bump_first = true` for the same `(owner_id, contract_id)` pair.
pub async fn sign_with_nonce(
&self,
identity_contract_nonce: IdentityNonce,
identity_public_key: &IdentityPublicKey,
signer: &impl Signer<IdentityPublicKey>,
platform_version: &PlatformVersion,
) -> Result<StateTransition, Error> {
let document_type = self
.data_contract
.document_type_for_name(&self.document_type_name)
.map_err(|e| Error::Protocol(e.into()))?;

ensure_revision_for_create(self.document.revision())?;
ensure_document_id_matches_entropy(
&self.document,
document_type,
&self.document_state_transition_entropy,
)?;
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Builder sign() and sign_with_nonce() duplicate document_type lookup + revision + id-vs-entropy checks

DocumentCreateTransitionBuilder::sign performs document_type_for_name, ensure_revision_for_create, and ensure_document_id_matches_entropy at lines 186-196, and sign_with_nonce then repeats all three at lines 243-253. The same duplication exists in packages/rs-sdk/src/platform/documents/transitions/replace.rs (lines 179 and 231).

The pre-nonce-alloc check is the load-bearing one (so failed validation never bumps the nonce); the inner re-check only protects callers that invoke sign_with_nonce directly. The duplication is cheap but invites drift if one site is updated and the other isn't. Either drop the inner re-check now that the outer one is in place, or add a brief doc-comment on sign_with_nonce noting why the defensive re-check is intentional.

source: ['claude']

Comment on lines +576 to +601
async fn build_and_sign_replace_after_nonce<S: Signer<IdentityPublicKey>>(
sdk: &Sdk,
document: &Document,
document_type: &DocumentType,
identity_public_key: &IdentityPublicKey,
token_payment_info: Option<TokenPaymentInfo>,
signer: &S,
settings: Option<PutSettings>,
new_identity_contract_nonce: u64,
) -> Result<StateTransition, Error> {
let put_settings = settings.unwrap_or_default();
let transition = BatchTransition::new_document_replacement_transition_from_document(
document.clone(),
document_type.as_ref(),
identity_public_key,
new_identity_contract_nonce,
put_settings.user_fee_increase.unwrap_or_default(),
token_payment_info,
signer,
sdk.version(),
put_settings.state_transition_creation_options,
)
.await?;
ensure_valid_state_transition_structure(&transition, sdk.version())?;
Ok(transition)
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Asymmetric ownership: create takes Document by value, replace takes &Document and clones internally

build_and_sign_create_after_nonce (line 478) was deliberately refactored to take document: Document by value so the document is moved into BatchTransition::new_document_creation_transition_from_document without an extra clone (see the function doc-comment claiming "a single Document clone end-to-end"). build_and_sign_replace_after_nonce (line 576) still takes &Document and clones at line 588 before passing it into BatchTransition::new_document_replacement_transition_from_document.

Mirroring the create path's owned-document signature on replace would drop one Document clone per call and keep the "single Document clone end-to-end" invariant symmetric across create and replace.

source: ['claude']

Comment on lines +706 to +718
export interface PrepareDocumentCreateOptions {
/**
* The document to create.
*
* **Migration note (id ↔ entropy invariant):** `document.id` must match
* the id derived from `(dataContractId, ownerId, documentTypeName, entropy)`
* via the v0 document-id derivation. Mismatches are rejected with
* `InvalidArgument` before any identity-contract nonce is allocated, so
* failed calls do not advance the local nonce cache. The `Document`
* constructor derives both together by default; if you set the id or
* entropy explicitly, keep them consistent.
*/
document: Document;
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: PrepareDocument*Options TS types no longer match the plain-object shapes the runtime accepts

PrepareDocumentCreateOptions / PrepareDocumentReplaceOptions advertise document: Document, but extract_prepare_document (lines 1681+) intentionally accepts either a real Document instance or a plain-object { id, ownerId, dataContractId, documentTypeName, properties, … } bag, and the new unit tests in packages/wasm-sdk/tests/unit/prepare-document.spec.ts only exercise the plain-object shape.

Typed TS consumers therefore cannot use the structural plain-object path without an as any cast. Update the generated TypeScript declarations to express Document | PlainDocumentOptions (with PlainDocumentOptions defined alongside) so the declared ABI matches the runtime contract. The same mismatch propagates to the one-shot documentCreate / documentReplace after their migration to the shared extractor.

source: ['codex']

pub mod unshield;
pub mod update_price_of_document;
pub(crate) mod validation;
pub mod validation;
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: validation module widened from pub(crate) to pub without explicit stability commitment

Changing pub(crate) mod validation; to pub mod validation; makes ensure_valid_state_transition_structure and map_validation_result part of the rs-sdk public API. The module doc-comment justifies the UnsupportedFeatureError filtering behavior (including the "all errors are unsupported → Ok" semantic) as an internal design choice — but exposing this publicly freezes that behavior as a stability commitment to downstream native crates.

If the intent is workspace-only sharing for the wasm-sdk regression test, prefer pub(crate) plus a #[cfg(any(test, feature = "internal"))] re-export, or move the regression test inside rs-sdk. If the public exposure is intentional, mark the module's doc-comment as a stable API surface so the filtering semantics are explicitly contractual.

source: ['claude']

Comment on lines +278 to +302
#[deprecated(
note = "use build_signed_document_create_transition or build_signed_document_replace_transition for strict intent validation"
)]
pub async fn build_signed_document_create_or_replace_transition<S: Signer<IdentityPublicKey>>(
sdk: &Sdk,
document: &Document,
document_type: &DocumentType,
document_state_transition_entropy: Option<[u8; 32]>,
identity_public_key: &IdentityPublicKey,
token_payment_info: Option<TokenPaymentInfo>,
signer: &S,
settings: Option<PutSettings>,
) -> Result<StateTransition, Error> {
build_signed_document_create_or_replace_transition_legacy(
sdk,
document,
document_type,
document_state_transition_entropy,
identity_public_key,
token_payment_info,
signer,
settings,
)
.await
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Deprecated build_signed_document_create_or_replace_transition has no removal milestone

build_signed_document_create_or_replace_transition is marked #[deprecated(note = "…")] and forwards to _legacy, but it remains fully pub with no since / removal-version note. The deprecation alone won't migrate downstream native callers, so without a planned removal version or #[doc(hidden)] it will accrete forever alongside the strict helpers.

Either pair #[deprecated] with since = "…" and a note pointing at a removal milestone, or move the legacy dispatcher behind #[doc(hidden)] + a sealed pub(crate) wrapper so the public API is just the strict helpers.

source: ['claude']

Comment on lines +43 to +69
fn map_validation_result(mut result: SimpleConsensusValidationResult) -> Result<(), Error> {
if result.is_valid() {
return Ok(());
}

if result.errors.iter().all(is_unsupported_feature_error) {
return Ok(());
}

// Mixed `UnsupportedFeatureError` + real-error case. The default
// `From<SimpleConsensusValidationResult> for Error` conversion keeps
// the *first* error as a typed `ConsensusError`. Stable-partition the
// list so real failures come first, ensuring the typed error returned
// is the most actionable one and not an `UnsupportedFeatureError`
// placeholder. We deliberately use the existing `From` conversion so
// the returned `Error` preserves the typed `ConsensusError` variant
// for downstream pattern-matching, instead of being flattened into a
// `ProtocolError::Generic` string.
result.errors.sort_by_key(|e| {
if is_unsupported_feature_error(e) {
1
} else {
0
}
});
Err(Error::from(result))
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: PR description says map_validation_result "filters" UnsupportedFeatureError but it actually sorts

The PR description's compatibility note claims ensure_valid_state_transition_structure filters UnsupportedFeatureError entries out of mixed structure-validation results. The implementation does not filter — it stable-sorts the errors so unsupported entries come last, then converts via the existing From<SimpleConsensusValidationResult> for Error, which surfaces only the first error.

Consumer-visible effect is similar (the typed Error is the first non-unsupported entry), but result.errors still contains every unsupported entry internally. Update the PR description (or the module doc-comment) so downstream callers reading either source aren't misled about iteration / count / errors.len() semantics.

source: ['claude']

Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

PR introduces two-phase document state-transition APIs in wasm-sdk plus strict rs-sdk builders with nonce-rollback discipline. Implementation is thorough and well-documented. The most contested finding is the new Document __type/__wbg_ptr fast path: three codex agents flag it as a blocking memory-safety regression at the JS→Wasm boundary, while two claude agents accept it as matching the documented trust-model carve-out (consistent with prior identityKey/signer handling). After verification, the module-level docs explicitly accept this trade-off, so it is surfaced as a high-confidence suggestion rather than blocking. Remaining findings are minor: docs/wording drift, duplicated version dispatch across crates, redundant checks, and small clone-perf nits.

Reviewed commit: cf1cda4

🟡 5 suggestion(s) | 💬 5 nitpick(s)

1 additional finding

💬 nitpick: Revision and id-entropy validations run twice in `DocumentCreateTransitionBuilder`

packages/rs-sdk/src/platform/documents/transitions/create.rs (lines 204-290)

sign calls ensure_revision_for_create and ensure_document_id_matches_entropy before allocating a nonce, then sign_with_nonce repeats both checks (each ensure_document_id_matches_entropy recomputes Document::generate_document_id_v0 from scratch). Each entry point needs to be safe when called directly, but the duplication means a future loosening/tightening of either check must be applied in two sites — not caught by tests since both currently use identical inputs. Extract a single private validate(&self, platform_version) helper called from both entry points so the contract is stated once. Note this duplication does not exist on the replace builder, so the two builders drift relative to each other.

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 1709-1716: Document fast path dereferences caller-controlled `__wbg_ptr` on a public JS input
  The new fast path in `extract_prepare_document` routes any JS value reporting `__type === "Document"` through `DocumentWasm::try_from(&document_js)`, which in `wasm-dpp2::utils::generic_of_js_val` reads `__wbg_ptr` and calls `unsafe { T::ref_from_abi(ptr_u32) }` with no provenance check. Because `__type` and `__wbg_ptr` are ordinary JS properties on the publicly-exported `documentCreate` / `documentReplace` / `prepareDocumentCreate` / `prepareDocumentReplace` entry points, a malicious in-process JS caller can forge `{ __type: 'Document', __wbg_ptr: <chosen u32> }` and force the wasm module to dereference an attacker-chosen pointer. The damage is bounded by the wasm sandbox — the bogus deref cannot escape linear memory — but it can still trap the wasm instance or read/corrupt other wasm-resident objects (e.g. cached signing material in `IdentitySigner`) for the same SDK instance.

This matches the existing carve-outs for `identityKey` and `signer` documented at the top of the file, and the module docstring (lines 99–103) explicitly puts forged-`__wbg_ptr` inputs out of scope of the API's safety guarantees. Unlike `IdentitySigner` (which has no reconstructable public surface) and `IdentityPublicKey` (only ever produced inside the SDK), `Document` legitimately crosses an untrusted boundary from JSON / object literals — and the structural fallback already in this function handles those inputs without ever touching `__wbg_ptr`. The motivation for the trusted branch is preserving typed `platform_value` properties (Identifier/Bytes/U64) on real `Document` class instances, which is a real correctness gain for legitimate callers.

Two options worth weighing:

1. Drop the fast path entirely and rely on the structural extraction for all inputs, accepting any typed-property degradation as the cost of a tighter trust boundary on a publicly-exported API.
2. Keep the fast path but gate it on a brand check that is not a publicly-spoofable JS property (e.g. a WeakSet membership probe maintained by wasm-dpp2, or a private symbol-keyed brand). This preserves the typed-property gain while closing the spoofed-`__type`/`__wbg_ptr` hole.

The `get_class_type` precheck is also informational only — `DocumentWasm::try_from` re-reads `__type` internally and trusts `__wbg_ptr` regardless — so it should not be read as an authorization gate by future maintainers.
- [SUGGESTION] lines 1816-1855: Version-dispatch logic for document-id derivation is duplicated across wasm-sdk and rs-sdk
  `ensure_document_id_matches_entropy_fast` independently re-implements the platform-version match on `derive_document_id` that already lives in `rs-sdk` (`put_document.rs::derive_document_id`). Both arms (`0 -> generate_document_id_v0`, other -> error) are mirrored. The wasm side is documented as an early-reject optimization, but any future bump to `derive_document_id = 1` requires synchronized edits in two crates — a classic drift hazard for a consensus-relevant derivation. Expose `derive_document_id` (or a thin `validate_id_matches_entropy`) as `pub` from `rs-sdk` so the wasm fast path calls the same function rather than maintaining a parallel dispatch table.

In `packages/rs-sdk/src/platform/transition/put_settings.rs`:
- [SUGGESTION] lines 58-74: `split_dedicated_fields` silently nulls a prior settings-derived value when re-issued with `None`
  When `*_explicit` is false, `split_dedicated_fields` unconditionally writes `self.user_fee_increase` (which may be `None`) into the dedicated field. The sequence `with_settings(PutSettings { user_fee_increase: Some(7), .. })` followed by `with_settings(PutSettings { user_fee_increase: None, .. })` therefore silently nulls out the previously settings-derived value. The doc claim that a second `with_settings` call "replaces the prior settings-derived value" is satisfied in the strict sense (`None` *is* a replacement), but only the Some→Some replacement case is tested. Either gate the overwrite on `Some(_)` or add a test that pins the current None-overwrite semantics so callers understand re-issuing a partial `PutSettings` clears prior settings-derived overrides.

In `packages/rs-sdk/src/platform/transition/validation.rs`:
- [SUGGESTION] lines 17-69: PR description says `UnsupportedFeatureError` entries are filtered out, but the implementation reorders them
  The PR description states `ensure_valid_state_transition_structure` *filters* `UnsupportedFeatureError` entries out of mixed structure-validation results. The actual implementation in `map_validation_result` does not filter — it stable-sorts the error list so non-`UnsupportedFeatureError` entries come first, and the `From<SimpleConsensusValidationResult> for Error` conversion then picks the first error. `UnsupportedFeatureError` entries remain inside the `SimpleConsensusValidationResult` even when a real error is present; only the *first* typed error returned to callers changes. The reorder-not-filter behavior is arguably better (it preserves full diagnostics and only changes which error becomes the typed primary), but the PR description and any downstream consumers reading it may write tests for the wrong invariant. Update the PR wording, or actually drop the unsupported entries.
- [SUGGESTION] lines 43-69: All-`UnsupportedFeatureError` pass-through is identified by error contents, not state-transition kind
  `map_validation_result` treats any validation result whose errors are entirely `UnsupportedFeatureError` as a no-op pass. This was previously `pub(crate)`-only; the PR now exposes it as `pub` and routes every prepare/strict-create/replace/delete path through it, broadening the blast radius. The implicit contract is "DPP uses an all-unsupported result as the sentinel for 'structure validation not implemented for this ST kind'." If a future DPP version legitimately emits a single `UnsupportedFeatureError` to report a real-but-unsupported sub-feature on a state transition whose other structural checks happen to pass, the pre-broadcast guard will silently accept the ST and broadcast it. The local nonce-rollback contract assumes structure validation is a real fail-fast gate. Detecting the "validation not implemented" sentinel via the state-transition variant (e.g. `matches!(state_transition, StateTransition::IdentityCreate(_) | ...)`) is more robust than inferring it from error contents. Inline docs explain the current behavior well, so this is non-blocking.

Comment on lines +1709 to +1716
if document_js.is_object()
&& get_class_type(&document_js)
.map_err(|err| WasmSdkError::invalid_argument(err.to_string()))?
== "Document"
{
return DocumentWasm::try_from(&document_js)
.map_err(|err| WasmSdkError::invalid_argument(err.to_string()));
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Document fast path dereferences caller-controlled __wbg_ptr on a public JS input

The new fast path in extract_prepare_document routes any JS value reporting __type === "Document" through DocumentWasm::try_from(&document_js), which in wasm-dpp2::utils::generic_of_js_val reads __wbg_ptr and calls unsafe { T::ref_from_abi(ptr_u32) } with no provenance check. Because __type and __wbg_ptr are ordinary JS properties on the publicly-exported documentCreate / documentReplace / prepareDocumentCreate / prepareDocumentReplace entry points, a malicious in-process JS caller can forge { __type: 'Document', __wbg_ptr: <chosen u32> } and force the wasm module to dereference an attacker-chosen pointer. The damage is bounded by the wasm sandbox — the bogus deref cannot escape linear memory — but it can still trap the wasm instance or read/corrupt other wasm-resident objects (e.g. cached signing material in IdentitySigner) for the same SDK instance.

This matches the existing carve-outs for identityKey and signer documented at the top of the file, and the module docstring (lines 99–103) explicitly puts forged-__wbg_ptr inputs out of scope of the API's safety guarantees. Unlike IdentitySigner (which has no reconstructable public surface) and IdentityPublicKey (only ever produced inside the SDK), Document legitimately crosses an untrusted boundary from JSON / object literals — and the structural fallback already in this function handles those inputs without ever touching __wbg_ptr. The motivation for the trusted branch is preserving typed platform_value properties (Identifier/Bytes/U64) on real Document class instances, which is a real correctness gain for legitimate callers.

Two options worth weighing:

  1. Drop the fast path entirely and rely on the structural extraction for all inputs, accepting any typed-property degradation as the cost of a tighter trust boundary on a publicly-exported API.
  2. Keep the fast path but gate it on a brand check that is not a publicly-spoofable JS property (e.g. a WeakSet membership probe maintained by wasm-dpp2, or a private symbol-keyed brand). This preserves the typed-property gain while closing the spoofed-__type/__wbg_ptr hole.

The get_class_type precheck is also informational only — DocumentWasm::try_from re-reads __type internally and trusts __wbg_ptr regardless — so it should not be read as an authorization gate by future maintainers.

source: ['claude', 'codex']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 1709-1716: Document fast path dereferences caller-controlled `__wbg_ptr` on a public JS input
  The new fast path in `extract_prepare_document` routes any JS value reporting `__type === "Document"` through `DocumentWasm::try_from(&document_js)`, which in `wasm-dpp2::utils::generic_of_js_val` reads `__wbg_ptr` and calls `unsafe { T::ref_from_abi(ptr_u32) }` with no provenance check. Because `__type` and `__wbg_ptr` are ordinary JS properties on the publicly-exported `documentCreate` / `documentReplace` / `prepareDocumentCreate` / `prepareDocumentReplace` entry points, a malicious in-process JS caller can forge `{ __type: 'Document', __wbg_ptr: <chosen u32> }` and force the wasm module to dereference an attacker-chosen pointer. The damage is bounded by the wasm sandbox — the bogus deref cannot escape linear memory — but it can still trap the wasm instance or read/corrupt other wasm-resident objects (e.g. cached signing material in `IdentitySigner`) for the same SDK instance.

This matches the existing carve-outs for `identityKey` and `signer` documented at the top of the file, and the module docstring (lines 99–103) explicitly puts forged-`__wbg_ptr` inputs out of scope of the API's safety guarantees. Unlike `IdentitySigner` (which has no reconstructable public surface) and `IdentityPublicKey` (only ever produced inside the SDK), `Document` legitimately crosses an untrusted boundary from JSON / object literals — and the structural fallback already in this function handles those inputs without ever touching `__wbg_ptr`. The motivation for the trusted branch is preserving typed `platform_value` properties (Identifier/Bytes/U64) on real `Document` class instances, which is a real correctness gain for legitimate callers.

Two options worth weighing:

1. Drop the fast path entirely and rely on the structural extraction for all inputs, accepting any typed-property degradation as the cost of a tighter trust boundary on a publicly-exported API.
2. Keep the fast path but gate it on a brand check that is not a publicly-spoofable JS property (e.g. a WeakSet membership probe maintained by wasm-dpp2, or a private symbol-keyed brand). This preserves the typed-property gain while closing the spoofed-`__type`/`__wbg_ptr` hole.

The `get_class_type` precheck is also informational only — `DocumentWasm::try_from` re-reads `__type` internally and trusts `__wbg_ptr` regardless — so it should not be read as an authorization gate by future maintainers.

Comment on lines +1816 to +1855
fn ensure_document_id_matches_entropy_fast(
document_id: Identifier,
contract_id: Identifier,
owner_id: Identifier,
document_type_name: &str,
entropy: &[u8; 32],
platform_version: &dash_sdk::dpp::version::PlatformVersion,
) -> Result<(), WasmSdkError> {
let version = platform_version
.dpp
.document_versions
.document_method_versions
.derive_document_id;
let expected = match version {
0 => Document::generate_document_id_v0(
&contract_id,
&owner_id,
document_type_name,
entropy.as_slice(),
),
_ => {
return Err(WasmSdkError::invalid_argument(format!(
"unknown derive_document_id version {version}; \
the wasm-sdk fast id-vs-entropy check only knows v0. \
Upgrade wasm-sdk to a build that supports this platform version."
)));
}
};
if document_id != expected {
return Err(WasmSdkError::invalid_argument(format!(
"document.id does not match the platform-version-dispatched \
document-id derivation \
(dataContractId, ownerId, documentTypeName, entropy); \
expected {expected}, got {document_id}. \
The Document constructor derives both together by default; if you set the \
id or entropy explicitly, keep them consistent."
)));
}
Ok(())
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Version-dispatch logic for document-id derivation is duplicated across wasm-sdk and rs-sdk

ensure_document_id_matches_entropy_fast independently re-implements the platform-version match on derive_document_id that already lives in rs-sdk (put_document.rs::derive_document_id). Both arms (0 -> generate_document_id_v0, other -> error) are mirrored. The wasm side is documented as an early-reject optimization, but any future bump to derive_document_id = 1 requires synchronized edits in two crates — a classic drift hazard for a consensus-relevant derivation. Expose derive_document_id (or a thin validate_id_matches_entropy) as pub from rs-sdk so the wasm fast path calls the same function rather than maintaining a parallel dispatch table.

source: ['claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 1816-1855: Version-dispatch logic for document-id derivation is duplicated across wasm-sdk and rs-sdk
  `ensure_document_id_matches_entropy_fast` independently re-implements the platform-version match on `derive_document_id` that already lives in `rs-sdk` (`put_document.rs::derive_document_id`). Both arms (`0 -> generate_document_id_v0`, other -> error) are mirrored. The wasm side is documented as an early-reject optimization, but any future bump to `derive_document_id = 1` requires synchronized edits in two crates — a classic drift hazard for a consensus-relevant derivation. Expose `derive_document_id` (or a thin `validate_id_matches_entropy`) as `pub` from `rs-sdk` so the wasm fast path calls the same function rather than maintaining a parallel dispatch table.

Comment on lines +58 to +74
pub fn split_dedicated_fields(
mut self,
dedicated_user_fee_increase: &mut Option<UserFeeIncrease>,
user_fee_increase_explicit: bool,
dedicated_state_transition_creation_options: &mut Option<StateTransitionCreationOptions>,
state_transition_creation_options_explicit: bool,
) -> Self {
if !user_fee_increase_explicit {
*dedicated_user_fee_increase = self.user_fee_increase;
}
if !state_transition_creation_options_explicit {
*dedicated_state_transition_creation_options = self.state_transition_creation_options;
}
self.user_fee_increase = None;
self.state_transition_creation_options = None;
self
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: split_dedicated_fields silently nulls a prior settings-derived value when re-issued with None

When *_explicit is false, split_dedicated_fields unconditionally writes self.user_fee_increase (which may be None) into the dedicated field. The sequence with_settings(PutSettings { user_fee_increase: Some(7), .. }) followed by with_settings(PutSettings { user_fee_increase: None, .. }) therefore silently nulls out the previously settings-derived value. The doc claim that a second with_settings call "replaces the prior settings-derived value" is satisfied in the strict sense (None is a replacement), but only the Some→Some replacement case is tested. Either gate the overwrite on Some(_) or add a test that pins the current None-overwrite semantics so callers understand re-issuing a partial PutSettings clears prior settings-derived overrides.

source: ['claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/transition/put_settings.rs`:
- [SUGGESTION] lines 58-74: `split_dedicated_fields` silently nulls a prior settings-derived value when re-issued with `None`
  When `*_explicit` is false, `split_dedicated_fields` unconditionally writes `self.user_fee_increase` (which may be `None`) into the dedicated field. The sequence `with_settings(PutSettings { user_fee_increase: Some(7), .. })` followed by `with_settings(PutSettings { user_fee_increase: None, .. })` therefore silently nulls out the previously settings-derived value. The doc claim that a second `with_settings` call "replaces the prior settings-derived value" is satisfied in the strict sense (`None` *is* a replacement), but only the Some→Some replacement case is tested. Either gate the overwrite on `Some(_)` or add a test that pins the current None-overwrite semantics so callers understand re-issuing a partial `PutSettings` clears prior settings-derived overrides.

Comment on lines +17 to +69
/// Convert a structure-validation result into [`Error`], with one special
/// case for [`UnsupportedFeatureError`].
///
/// `UnsupportedFeatureError` has *two* meanings in DPP:
///
/// 1. **"Structure validation is not implemented for this state transition
/// kind"** — e.g. identity-based STs return a result that is *entirely*
/// `UnsupportedFeatureError` entries. In this case we treat the result
/// as a no-op pass so the prepare APIs can sign and broadcast these STs
/// even though their structure check is a stub.
/// 2. **"A specific feature inside an otherwise-validated ST is not
/// supported on this platform version"** — in this case the result
/// mixes `UnsupportedFeatureError` entries with real validation
/// failures. Here the unsupported entries are *not* placeholders: they
/// are legitimate rejections that explain why a particular sub-feature
/// is unavailable, and silently dropping them would discard
/// user-visible diagnostic information.
///
/// To honor both meanings we only treat the "all errors are unsupported"
/// case as `Ok`. Once *any* non-unsupported error is present we surface
/// the result via the existing `From<SimpleConsensusValidationResult> for
/// Error` conversion — which keeps the first error as a *typed*
/// `ConsensusError` so callers can pattern-match on it. To avoid the
/// conversion picking an `UnsupportedFeatureError` placeholder when a
/// real failure is also present, we first reorder the error list so the
/// first non-`UnsupportedFeatureError` entry is primary.
fn map_validation_result(mut result: SimpleConsensusValidationResult) -> Result<(), Error> {
if result.is_valid() {
return Ok(());
}

if result.errors.iter().all(is_unsupported_feature_error) {
return Ok(());
}

// Mixed `UnsupportedFeatureError` + real-error case. The default
// `From<SimpleConsensusValidationResult> for Error` conversion keeps
// the *first* error as a typed `ConsensusError`. Stable-partition the
// list so real failures come first, ensuring the typed error returned
// is the most actionable one and not an `UnsupportedFeatureError`
// placeholder. We deliberately use the existing `From` conversion so
// the returned `Error` preserves the typed `ConsensusError` variant
// for downstream pattern-matching, instead of being flattened into a
// `ProtocolError::Generic` string.
result.errors.sort_by_key(|e| {
if is_unsupported_feature_error(e) {
1
} else {
0
}
});
Err(Error::from(result))
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: PR description says UnsupportedFeatureError entries are filtered out, but the implementation reorders them

The PR description states ensure_valid_state_transition_structure filters UnsupportedFeatureError entries out of mixed structure-validation results. The actual implementation in map_validation_result does not filter — it stable-sorts the error list so non-UnsupportedFeatureError entries come first, and the From<SimpleConsensusValidationResult> for Error conversion then picks the first error. UnsupportedFeatureError entries remain inside the SimpleConsensusValidationResult even when a real error is present; only the first typed error returned to callers changes. The reorder-not-filter behavior is arguably better (it preserves full diagnostics and only changes which error becomes the typed primary), but the PR description and any downstream consumers reading it may write tests for the wrong invariant. Update the PR wording, or actually drop the unsupported entries.

source: ['claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/transition/validation.rs`:
- [SUGGESTION] lines 17-69: PR description says `UnsupportedFeatureError` entries are filtered out, but the implementation reorders them
  The PR description states `ensure_valid_state_transition_structure` *filters* `UnsupportedFeatureError` entries out of mixed structure-validation results. The actual implementation in `map_validation_result` does not filter — it stable-sorts the error list so non-`UnsupportedFeatureError` entries come first, and the `From<SimpleConsensusValidationResult> for Error` conversion then picks the first error. `UnsupportedFeatureError` entries remain inside the `SimpleConsensusValidationResult` even when a real error is present; only the *first* typed error returned to callers changes. The reorder-not-filter behavior is arguably better (it preserves full diagnostics and only changes which error becomes the typed primary), but the PR description and any downstream consumers reading it may write tests for the wrong invariant. Update the PR wording, or actually drop the unsupported entries.

Comment on lines +43 to +69
fn map_validation_result(mut result: SimpleConsensusValidationResult) -> Result<(), Error> {
if result.is_valid() {
return Ok(());
}

if result.errors.iter().all(is_unsupported_feature_error) {
return Ok(());
}

// Mixed `UnsupportedFeatureError` + real-error case. The default
// `From<SimpleConsensusValidationResult> for Error` conversion keeps
// the *first* error as a typed `ConsensusError`. Stable-partition the
// list so real failures come first, ensuring the typed error returned
// is the most actionable one and not an `UnsupportedFeatureError`
// placeholder. We deliberately use the existing `From` conversion so
// the returned `Error` preserves the typed `ConsensusError` variant
// for downstream pattern-matching, instead of being flattened into a
// `ProtocolError::Generic` string.
result.errors.sort_by_key(|e| {
if is_unsupported_feature_error(e) {
1
} else {
0
}
});
Err(Error::from(result))
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: All-UnsupportedFeatureError pass-through is identified by error contents, not state-transition kind

map_validation_result treats any validation result whose errors are entirely UnsupportedFeatureError as a no-op pass. This was previously pub(crate)-only; the PR now exposes it as pub and routes every prepare/strict-create/replace/delete path through it, broadening the blast radius. The implicit contract is "DPP uses an all-unsupported result as the sentinel for 'structure validation not implemented for this ST kind'." If a future DPP version legitimately emits a single UnsupportedFeatureError to report a real-but-unsupported sub-feature on a state transition whose other structural checks happen to pass, the pre-broadcast guard will silently accept the ST and broadcast it. The local nonce-rollback contract assumes structure validation is a real fail-fast gate. Detecting the "validation not implemented" sentinel via the state-transition variant (e.g. matches!(state_transition, StateTransition::IdentityCreate(_) | ...)) is more robust than inferring it from error contents. Inline docs explain the current behavior well, so this is non-blocking.

source: ['claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/transition/validation.rs`:
- [SUGGESTION] lines 43-69: All-`UnsupportedFeatureError` pass-through is identified by error contents, not state-transition kind
  `map_validation_result` treats any validation result whose errors are entirely `UnsupportedFeatureError` as a no-op pass. This was previously `pub(crate)`-only; the PR now exposes it as `pub` and routes every prepare/strict-create/replace/delete path through it, broadening the blast radius. The implicit contract is "DPP uses an all-unsupported result as the sentinel for 'structure validation not implemented for this ST kind'." If a future DPP version legitimately emits a single `UnsupportedFeatureError` to report a real-but-unsupported sub-feature on a state transition whose other structural checks happen to pass, the pre-broadcast guard will silently accept the ST and broadcast it. The local nonce-rollback contract assumes structure validation is a real fail-fast gate. Detecting the "validation not implemented" sentinel via the state-transition variant (e.g. `matches!(state_transition, StateTransition::IdentityCreate(_) | ...)`) is more robust than inferring it from error contents. Inline docs explain the current behavior well, so this is non-blocking.

Comment on lines +559 to +579
// Resolve the owned document type from the contract.
let document_type = data_contract
.document_type_cloned_for_name(&document_type_name)
.map_err(|e| Error::Protocol(e.into()))?;

// Route through the strict create helper so the one-shot
// `document_create` builder API gets the same fail-fast revision
// and id-matches-entropy validation as the wasm-sdk
// `prepareDocumentCreate` path. Pre-broadcast errors roll back the
// allocated identity-contract nonce inside the helper.
let state_transition = build_signed_document_create_transition(
self,
&document,
&document_type,
document_state_transition_entropy,
signing_key,
token_payment_info,
signer,
Some(effective_settings),
)
.await?;
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Sdk::document_create performs an extra Document clone on the one-shot path

Sdk::document_create destructures the builder so it owns document outright, but then passes it to build_signed_document_create_transition(self, &document, ...) by reference. That helper unconditionally clones the document on line 425 before forwarding to the owned variant build_signed_document_create_transition_owned. Since document_create already owns the value, it could call the _owned variant directly and achieve the "single Document clone end-to-end" property the module docstring claims. Exposing the _owned helper as pub(crate) would let documents/transitions/create.rs skip the extra clone. Replace path is bottlenecked on &Document end-to-end and unaffected.

source: ['claude']

Comment on lines +1573 to +1589
fn extract_optional_identifier_property(
container: &JsValue,
property_name: &str,
) -> Result<Option<Identifier>, WasmSdkError> {
let value = Reflect::get(container, &JsValue::from_str(property_name)).map_err(|err| {
WasmSdkError::invalid_argument(format!(
"failed to read '{}' from options: {:?}",
property_name, err
))
})?;

if value.is_undefined() || value.is_null() {
return Ok(None);
}

extract_identifier_property(container, property_name).map(Some)
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: extract_optional_identifier_property reads the JS property twice

The helper first calls Reflect::get(container, property_name) to check for undefined/null, and on success delegates to extract_identifier_property(container, property_name), which performs the exact same Reflect::get again. For a class instance whose propertyName is implemented as a JS getter, the getter fires twice: any side effects run twice, and a TOCTOU window opens where the second read could return null after the first non-null read — in which case the function would surface the misleading '<x>' is required error from extract_identifier_property instead of Ok(None). Forward the already-fetched JsValue into a shared inner helper to avoid both issues.

source: ['claude']

Comment on lines +933 to +937
let document: Document = document_wasm.clone().into();

ensure_document_replace_revision(document.revision(), "prepareDocumentCreate")?;

// Get metadata from document
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Plain-JS-array identifier path silently coerces non-byte elements

When the value is a plain JS Array, js_sys::Uint8Array::from(value.clone()) invokes JS Uint8Array.from(...), which coerces every element via ToNumber then ToUint8 (mod-256 wrap, NaN/undefined -> 0). The result is then handed to identifier_from_bytes, which validates only the length (32). A caller mistake like passing [1, 'two', 3.7, 300, ...] yields a structurally valid Identifier with silently mutated bytes instead of being rejected. Either validate each element is an integer in 0..=255 before coercion, or document that non-byte array elements are silently coerced.

source: ['claude']

Comment on lines +158 to +173
// Delegate the nonce-allocate / sign / structure-validate / rollback
// sequence to rs-sdk's shared helper. Any pre-broadcast failure
// inside the helper (sign or local structure validation) rolls the
// bumped identity-contract nonce back internally, so the local
// nonce cache cannot advance past a nonce the network never
// observed.
let state_transition = build_signed_document_delete_transition(
&wrapper.sdk,
&builder,
identity_public_key,
signer,
)
.await
.map_err(|e| {
FFIError::InternalError(format!("Failed to create delete transition: {}", e))
})?;
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: FFI delete path can unwind Rust panics across the extern "C" boundary

dash_sdk_document_delete is pub unsafe extern "C" and awaits build_signed_document_delete_transition(...). The helper performs nonce allocation, signing, structure validation and rollback; any panic inside it (e.g. an unwrap deep in dpp serialization) would unwind across the extern "C" boundary, which is UB on stable Rust. The rest of this FFI module already shares this property, so this PR isn't introducing a new class of risk — it expands the surface that runs inside it. Confirm the crate is compiled with panic = "abort", or wrap the awaited future in AssertUnwindSafe(...).catch_unwind() at the FFI entry.

source: ['claude']

@thepastaclaw thepastaclaw force-pushed the feat/sdk-prepare-document-apis branch from cf1cda4 to a523a1e Compare May 14, 2026 07:26
Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

One blocking FFI regression: dash_sdk_document_put_to_platform uses unwrap_or(0) for the create/replace branch decision while dash_sdk_document_create produces revision = None for callers that omit revision — after this PR the strict replace-revision guard rejects those flows, and the wait-variant of the same FFI uses unwrap_or(1) so the two paths now disagree. Otherwise the prepare API design, nonce-rollback semantics, validation-reorder, and structural extraction are sound. Several lower-severity follow-ups: the new typed Error::InvalidArgument is squashed to InternalError by the FFI map_err pattern, the create/replace revision rules are duplicated between wasm-sdk and rs-sdk, and the PutDocument::put_to_platform create path is semver-significant. Dropped 3 findings as false positives.

Reviewed commit: a523a1e

🔴 1 blocking | 🟡 3 suggestion(s) | 💬 3 nitpick(s)

1 additional finding

🔴 blocking: `dash_sdk_document_put_to_platform` misroutes `revision = None` creates into the now-strict replace path

packages/rs-sdk-ffi/src/document/put.rs (line 116)

dash_sdk_document_create maps params.revision == 0 to revision: None (create.rs:336-340). On this no-wait FFI entry, document.revision().unwrap_or(0) == 1 evaluates to false for that input and falls through to DocumentReplaceTransitionBuilder. After this PR, DocumentReplaceTransitionBuilder::sign runs ensure_revision_for_replace(self.document.revision()) (replace.rs:201, also at 253), which rejects None with Error::InvalidArgument("document must have a revision set for replace; ...").

The wait-variant dash_sdk_document_put_to_platform_and_wait uses unwrap_or(1) == 1 at put.rs:288 and still treats this case as create, so the two FFI APIs now disagree on identical inputs. Pre-existing FFI/Swift callers that built a document via dash_sdk_document_create with the revision left unset and then handed it to the no-wait put will see this fail only on the serialized path.

This is a behavioral regression caused by the PR's tightening. Align the no-wait branch with the wait-variant.

💡 Suggested change
        let state_transition = if document.revision().unwrap_or(1) == 1 {
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk-ffi/src/document/put.rs`:
- [BLOCKING] line 116: `dash_sdk_document_put_to_platform` misroutes `revision = None` creates into the now-strict replace path
  `dash_sdk_document_create` maps `params.revision == 0` to `revision: None` (create.rs:336-340). On this no-wait FFI entry, `document.revision().unwrap_or(0) == 1` evaluates to `false` for that input and falls through to `DocumentReplaceTransitionBuilder`. After this PR, `DocumentReplaceTransitionBuilder::sign` runs `ensure_revision_for_replace(self.document.revision())` (replace.rs:201, also at 253), which rejects `None` with `Error::InvalidArgument("document must have a revision set for replace; ...")`.

The wait-variant `dash_sdk_document_put_to_platform_and_wait` uses `unwrap_or(1) == 1` at put.rs:288 and still treats this case as create, so the two FFI APIs now disagree on identical inputs. Pre-existing FFI/Swift callers that built a document via `dash_sdk_document_create` with the revision left unset and then handed it to the no-wait put will see this fail only on the serialized path.

This is a behavioral regression caused by the PR's tightening. Align the no-wait branch with the wait-variant.

In `packages/rs-sdk-ffi/src/document/delete.rs`:
- [SUGGESTION] lines 170-173: Typed `Error::InvalidArgument` dispatch is dead through every document FFI path
  This PR adds `Error::InvalidArgument` to `rs-sdk` (error.rs:99-103) and a matching typed branch in `rs-sdk-ffi/src/error.rs:104-111` that maps it to `DashSDKErrorCode::InvalidParameter`. That branch only fires for `FFIError::SDKError(_)`. Every document FFI callsite — `delete.rs:171-173`, `put.rs:182-184` / `:317-319` / `:354-356`, `create.rs`, `replace.rs:152-154` / `:357-360`, `transfer.rs`, `price.rs`, `purchase.rs` — wraps the SDK error as `FFIError::InternalError(format!("...: {}", e))`, which routes through `FFIError::InternalError(_) => (DashSDKErrorCode::InternalError, ...)` (error.rs:161) and discards the typed information.

Net effect for iOS/FFI consumers: a `prepare`-style flow that fails `ensure_revision_for_create`/`ensure_revision_for_replace`/`ensure_document_id_matches_entropy` surfaces with `DashSDKErrorCode::InternalError` instead of `InvalidParameter`. Code clients that branch on error code (the documented FFI contract) treat caller-input bugs as internal SDK bugs.

Easiest fix: pass the SDK error through `FFIError::SDKError(e)` (or `FFIError::from(e)` via the existing `#[from]` impl) so the typed dispatch fires. If the descriptive prefix matters, special-case `Error::InvalidArgument` before wrapping the rest as `InternalError`. As-is, the typed dispatch added in this PR has no observable effect through any in-tree FFI document path.

In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 1841-1887: Revision intent rules duplicated across wasm-sdk and rs-sdk
  `ensure_document_create_revision` / `ensure_document_replace_revision` re-encode the acceptance rules already implemented in rs-sdk as `ensure_revision_for_create` / `ensure_revision_for_replace` (put_document.rs:96-109). The wasm copies exist to fail fast before contract fetch, but they become a second source of truth for API semantics, with their own error messages and tests. Any future change to the create/replace boundary now has to be kept in sync across two crates. Given that this PR is explicitly tightening the create-vs-replace contract, the duplication raises the risk of JS-facing behavior drifting from the native builders. Consider re-exporting the rs-sdk helpers (or a thin shared validator) so both layers share one source of truth.

In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 724-809: `PutDocument::put_to_platform` create path is now semver-significant
  The legacy `PutDocument::put_to_platform` create path now routes through `build_signed_document_create_transition` / `_owned` and enforces id-matches-entropy when `document_state_transition_entropy = Some(entropy)`, plus rejects `Some(0)` revisions via `ensure_revision_nonzero`. The PR description verifies in-tree consumers are unaffected, but `PutDocument` is a public trait, and out-of-tree Rust SDK consumers may have built `Document` objects with hand-picked ids alongside `Some(entropy)` or relied on `Some(0)` revision being silently accepted. Those callers will now get `Error::InvalidArgument` before any nonce allocation. Worth flagging in CHANGELOG / release notes as a semver-significant tightening of an existing public API.

Comment on lines +170 to +173
.await
.map_err(|e| {
FFIError::InternalError(format!("Failed to create delete transition: {}", e))
})?;
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Typed Error::InvalidArgument dispatch is dead through every document FFI path

This PR adds Error::InvalidArgument to rs-sdk (error.rs:99-103) and a matching typed branch in rs-sdk-ffi/src/error.rs:104-111 that maps it to DashSDKErrorCode::InvalidParameter. That branch only fires for FFIError::SDKError(_). Every document FFI callsite — delete.rs:171-173, put.rs:182-184 / :317-319 / :354-356, create.rs, replace.rs:152-154 / :357-360, transfer.rs, price.rs, purchase.rs — wraps the SDK error as FFIError::InternalError(format!("...: {}", e)), which routes through FFIError::InternalError(_) => (DashSDKErrorCode::InternalError, ...) (error.rs:161) and discards the typed information.

Net effect for iOS/FFI consumers: a prepare-style flow that fails ensure_revision_for_create/ensure_revision_for_replace/ensure_document_id_matches_entropy surfaces with DashSDKErrorCode::InternalError instead of InvalidParameter. Code clients that branch on error code (the documented FFI contract) treat caller-input bugs as internal SDK bugs.

Easiest fix: pass the SDK error through FFIError::SDKError(e) (or FFIError::from(e) via the existing #[from] impl) so the typed dispatch fires. If the descriptive prefix matters, special-case Error::InvalidArgument before wrapping the rest as InternalError. As-is, the typed dispatch added in this PR has no observable effect through any in-tree FFI document path.

source: ['claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk-ffi/src/document/delete.rs`:
- [SUGGESTION] lines 170-173: Typed `Error::InvalidArgument` dispatch is dead through every document FFI path
  This PR adds `Error::InvalidArgument` to `rs-sdk` (error.rs:99-103) and a matching typed branch in `rs-sdk-ffi/src/error.rs:104-111` that maps it to `DashSDKErrorCode::InvalidParameter`. That branch only fires for `FFIError::SDKError(_)`. Every document FFI callsite — `delete.rs:171-173`, `put.rs:182-184` / `:317-319` / `:354-356`, `create.rs`, `replace.rs:152-154` / `:357-360`, `transfer.rs`, `price.rs`, `purchase.rs` — wraps the SDK error as `FFIError::InternalError(format!("...: {}", e))`, which routes through `FFIError::InternalError(_) => (DashSDKErrorCode::InternalError, ...)` (error.rs:161) and discards the typed information.

Net effect for iOS/FFI consumers: a `prepare`-style flow that fails `ensure_revision_for_create`/`ensure_revision_for_replace`/`ensure_document_id_matches_entropy` surfaces with `DashSDKErrorCode::InternalError` instead of `InvalidParameter`. Code clients that branch on error code (the documented FFI contract) treat caller-input bugs as internal SDK bugs.

Easiest fix: pass the SDK error through `FFIError::SDKError(e)` (or `FFIError::from(e)` via the existing `#[from]` impl) so the typed dispatch fires. If the descriptive prefix matters, special-case `Error::InvalidArgument` before wrapping the rest as `InternalError`. As-is, the typed dispatch added in this PR has no observable effect through any in-tree FFI document path.

Comment on lines +1841 to +1887
fn ensure_document_create_revision(
revision: Option<u64>,
replace_api_name: &str,
) -> Result<(), WasmSdkError> {
match revision {
None => Ok(()),
Some(rev) if rev == INITIAL_REVISION => Ok(()),
// `Some(0)` is invalid for *both* create and replace, so do not
// point users at the sibling API — they would just see the same
// rejection from `ensure_document_replace_revision`. Emit a
// dedicated message that makes the always-invalid value explicit.
Some(0) => Err(WasmSdkError::invalid_argument(format!(
"Document revision is 0 but revision 0 is invalid for both create and replace. \
Use unset or {} (INITIAL_REVISION) for create, or > {} for replace.",
INITIAL_REVISION, INITIAL_REVISION,
))),
Some(rev) => Err(WasmSdkError::invalid_argument(format!(
"Document revision is {} but create requires revision to be unset or {}. Use {} for existing documents.",
rev, INITIAL_REVISION, replace_api_name,
))),
}
}

fn ensure_document_replace_revision(
revision: Option<u64>,
create_api_name: &str,
) -> Result<(), WasmSdkError> {
match revision {
Some(rev) if rev > INITIAL_REVISION => Ok(()),
// `Some(0)` is invalid for *both* create and replace, so do not
// point users at the sibling API — they would just see the same
// rejection from `ensure_document_create_revision`. Emit a
// dedicated message that makes the always-invalid value explicit.
Some(0) => Err(WasmSdkError::invalid_argument(format!(
"Document revision is 0 but revision 0 is invalid for both create and replace. \
Use unset or {} (INITIAL_REVISION) for create, or > {} for replace.",
INITIAL_REVISION, INITIAL_REVISION,
))),
Some(rev) => Err(WasmSdkError::invalid_argument(format!(
"Document revision is {} but replace requires revision > {}. Use {} for new documents.",
rev, INITIAL_REVISION, create_api_name,
))),
None => Err(WasmSdkError::invalid_argument(format!(
"Document must have a revision set for replace. Use {} for new documents.",
create_api_name,
))),
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Revision intent rules duplicated across wasm-sdk and rs-sdk

ensure_document_create_revision / ensure_document_replace_revision re-encode the acceptance rules already implemented in rs-sdk as ensure_revision_for_create / ensure_revision_for_replace (put_document.rs:96-109). The wasm copies exist to fail fast before contract fetch, but they become a second source of truth for API semantics, with their own error messages and tests. Any future change to the create/replace boundary now has to be kept in sync across two crates. Given that this PR is explicitly tightening the create-vs-replace contract, the duplication raises the risk of JS-facing behavior drifting from the native builders. Consider re-exporting the rs-sdk helpers (or a thin shared validator) so both layers share one source of truth.

source: ['codex']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 1841-1887: Revision intent rules duplicated across wasm-sdk and rs-sdk
  `ensure_document_create_revision` / `ensure_document_replace_revision` re-encode the acceptance rules already implemented in rs-sdk as `ensure_revision_for_create` / `ensure_revision_for_replace` (put_document.rs:96-109). The wasm copies exist to fail fast before contract fetch, but they become a second source of truth for API semantics, with their own error messages and tests. Any future change to the create/replace boundary now has to be kept in sync across two crates. Given that this PR is explicitly tightening the create-vs-replace contract, the duplication raises the risk of JS-facing behavior drifting from the native builders. Consider re-exporting the rs-sdk helpers (or a thin shared validator) so both layers share one source of truth.

Comment on lines 724 to 809
@@ -58,64 +731,80 @@ impl<S: Signer<IdentityPublicKey>> PutDocument<S> for Document {
signer: &S,
settings: Option<PutSettings>,
) -> Result<StateTransition, Error> {
let new_identity_contract_nonce = sdk
.get_identity_contract_nonce(
self.owner_id(),
document_type.data_contract_id(),
true,
settings,
)
.await?;

let settings = settings.unwrap_or_default();
let transition = if self.revision().is_some()
&& self.revision().unwrap() != INITIAL_REVISION
{
BatchTransition::new_document_replacement_transition_from_document(
self.clone(),
document_type.as_ref(),
&identity_public_key,
new_identity_contract_nonce,
settings.user_fee_increase.unwrap_or_default(),
token_payment_info,
signer,
sdk.version(),
settings.state_transition_creation_options,
)
.await?
// Route through the strict create/replace helpers so callers get the
// same fail-fast revision-vs-intent guarantees as the wasm-sdk
// `prepareDocumentCreate` / `prepareDocumentReplace` paths. The
// dispatch is driven by the document revision: unset or
// `INITIAL_REVISION` selects create; revisions strictly greater than
// `INITIAL_REVISION` select replace.
//
// Reject `Some(0)` up front with the dispatch-aware
// `ensure_revision_nonzero` message rather than letting it fall into
// the replace branch — the replace-helper message says "use the
// create path", which would be misleading for `put_to_platform`
// callers (they aren't picking a branch themselves).
ensure_revision_nonzero(self.revision())?;
let transition = if self.revision().is_none() || self.revision() == Some(INITIAL_REVISION) {
// Create path. Avoid the outer pre-resolve clone when the
// caller already supplied entropy: pass `self` straight to the
// strict create helper, which clones once internally for
// `BatchTransition::new_document_creation_transition_from_document`.
//
// For the legacy `None` entropy fallback we resolve once here
// (generate entropy + rewrite document id) and hand the owned
// document to `build_signed_document_create_transition_owned`,
// so the create path performs a single Document clone end-to-end.
// The strict id-matches-entropy check runs before any nonce
// allocation in both branches.
match document_state_transition_entropy {
Some(entropy) => {
build_signed_document_create_transition(
sdk,
self,
&document_type,
entropy,
&identity_public_key,
token_payment_info,
signer,
settings,
)
.await?
}
None => {
let (resolved_document, resolved_entropy) =
resolve_document_create_entropy(self, &document_type, None, sdk.version())?;
build_signed_document_create_transition_owned(
sdk,
resolved_document,
&document_type,
resolved_entropy,
&identity_public_key,
token_payment_info,
signer,
settings,
)
.await?
}
}
} else {
let (document, document_state_transition_entropy) = document_state_transition_entropy
.map(|entropy| (self.clone(), entropy))
.unwrap_or_else(|| {
let mut rng = StdRng::from_entropy();
let mut document = self.clone();
let entropy = rng.gen::<[u8; 32]>();
document.set_id(Document::generate_document_id_v0(
&document_type.data_contract_id(),
&document.owner_id(),
document_type.name(),
entropy.as_slice(),
));
(document, entropy)
});
BatchTransition::new_document_creation_transition_from_document(
document,
document_type.as_ref(),
document_state_transition_entropy,
// Replace path: entropy is unused; the strict helper enforces
// `revision > INITIAL_REVISION`.
build_signed_document_replace_transition(
sdk,
self,
&document_type,
&identity_public_key,
new_identity_contract_nonce,
settings.user_fee_increase.unwrap_or_default(),
token_payment_info,
signer,
sdk.version(),
settings.state_transition_creation_options,
settings,
)
.await?
};
ensure_valid_state_transition_structure(&transition, sdk.version())?;

// response is empty for a broadcast, result comes from the stream wait for state transition result
transition.broadcast(sdk, Some(settings)).await?;
transition
.broadcast(sdk, Some(settings.unwrap_or_default()))
.await?;
Ok(transition)
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: PutDocument::put_to_platform create path is now semver-significant

The legacy PutDocument::put_to_platform create path now routes through build_signed_document_create_transition / _owned and enforces id-matches-entropy when document_state_transition_entropy = Some(entropy), plus rejects Some(0) revisions via ensure_revision_nonzero. The PR description verifies in-tree consumers are unaffected, but PutDocument is a public trait, and out-of-tree Rust SDK consumers may have built Document objects with hand-picked ids alongside Some(entropy) or relied on Some(0) revision being silently accepted. Those callers will now get Error::InvalidArgument before any nonce allocation. Worth flagging in CHANGELOG / release notes as a semver-significant tightening of an existing public API.

source: ['claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/transition/put_document.rs`:
- [SUGGESTION] lines 724-809: `PutDocument::put_to_platform` create path is now semver-significant
  The legacy `PutDocument::put_to_platform` create path now routes through `build_signed_document_create_transition` / `_owned` and enforces id-matches-entropy when `document_state_transition_entropy = Some(entropy)`, plus rejects `Some(0)` revisions via `ensure_revision_nonzero`. The PR description verifies in-tree consumers are unaffected, but `PutDocument` is a public trait, and out-of-tree Rust SDK consumers may have built `Document` objects with hand-picked ids alongside `Some(entropy)` or relied on `Some(0)` revision being silently accepted. Those callers will now get `Error::InvalidArgument` before any nonce allocation. Worth flagging in CHANGELOG / release notes as a semver-significant tightening of an existing public API.

Comment on lines 208 to +254
signer: &impl Signer<IdentityPublicKey>,
platform_version: &PlatformVersion,
) -> Result<StateTransition, Error> {
{
let document_type = self
.data_contract
.document_type_for_name(&self.document_type_name)
.map_err(|e| Error::Protocol(e.into()))?;
ensure_revision_for_create(self.document.revision())?;
ensure_document_id_matches_entropy(
&self.document,
document_type,
&self.document_state_transition_entropy,
platform_version,
)?;
}

let owner_id = self.document.owner_id();
let contract_id = self.data_contract.id();
let identity_contract_nonce = sdk
.get_identity_contract_nonce(
self.document.owner_id(),
self.data_contract.id(),
true,
self.settings,
)
.get_identity_contract_nonce(owner_id, contract_id, true, self.settings)
.await?;

match self
.sign_with_nonce(
identity_contract_nonce,
identity_public_key,
signer,
platform_version,
)
.await
{
Ok(transition) => Ok(transition),
Err(err) => {
sdk.rollback_identity_contract_nonce(
owner_id,
contract_id,
identity_contract_nonce,
)
.await;
Err(err)
}
}
}

/// Signs the document create transition using a pre-allocated
/// identity-contract nonce.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Pre-alloc validation in sign() is duplicated by sign_with_nonce

DocumentCreateTransitionBuilder::sign fetches document_type_for_name, runs ensure_revision_for_create, and runs ensure_document_id_matches_entropy before allocating a nonce. It then delegates to sign_with_nonce (create.rs:281-289), which repeats all three. The duplication is defensive (direct callers of sign_with_nonce still get the guarantee), but every sign(...) call now does the document-type lookup and the two checks twice. Same shape exists in replace.rs::sign. Consider documenting sign_with_nonce as the authoritative validator and either dropping the pre-alloc copy in sign or factoring shared validation into a helper that runs once. Not a correctness issue.

source: ['claude']

Comment on lines +1712 to +1716
let properties = try_from_options_with(&document_js, "properties", |v| {
let pv = wasm_dpp2::serialization::js_value_to_platform_value(v)?;
pv.into_btree_string_map()
.map_err(|err| wasm_dpp2::error::WasmDppError::invalid_argument(err.to_string()))
})?;
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: extract_prepare_document retypes 32-byte byteArray properties to Value::Identifier

extract_prepare_document rebuilds Document.properties via wasm_dpp2::serialization::js_value_to_platform_value, which unconditionally converts any 32-byte Uint8Array to Value::Identifier rather than Value::Bytes. This now affects every code path that flows through extract_prepare_document, including the one-shot documentCreate / documentReplace / documentTransfer / documentSetPrice / documentPurchase paths that previously used DocumentWasm::try_from.

In practice this looks safe today: Value::into_binary_bytes accepts Value::Identifier and returns the raw bytes, so the wire output for byteArray properties is unchanged. The concern is type-sensitive consumers (schema validators distinguishing Bytes vs Identifier, equality checks, or future code that branches on the variant). Worth a round-trip regression test: build a document with a 32-byte byteArray property, run it through prepareDocument*, and assert the resulting StateTransition matches the legacy path byte-for-byte.

source: ['claude']

Comment on lines +84 to +116
fn map_validation_result(mut result: SimpleConsensusValidationResult) -> Result<(), Error> {
if result.is_valid() {
return Ok(());
}

// Pass-through only when *every* error is the DPP sentinel. A
// non-sentinel `UnsupportedFeatureError` (case 2 above) is a real
// rejection and must surface as an `Err`.
if result.errors.iter().all(is_structure_validation_sentinel) {
return Ok(());
}

// Mixed real-error / `UnsupportedFeatureError` case. The default
// `From<SimpleConsensusValidationResult> for Error` conversion keeps
// the *first* error as a typed `ConsensusError`. Stable-sort so
// non-`UnsupportedFeatureError` failures come first, ensuring the
// typed error returned is the most actionable one and not an
// `UnsupportedFeatureError` entry. We deliberately use the existing
// `From` conversion so the returned `Error` preserves the typed
// `ConsensusError` variant for downstream pattern-matching, instead
// of being flattened into a `ProtocolError::Generic` string. Note
// this is a **reorder**, not a filter: every original error
// (sentinel and non-sentinel `UnsupportedFeatureError` alike)
// remains in the result.
result.errors.sort_by_key(|e| {
if is_any_unsupported_feature_error(e) {
1
} else {
0
}
});
Err(Error::from(result))
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: map_validation_result reorders errors — preserved order is no longer source-order

map_validation_result stable-sorts result.errors so non-UnsupportedFeatureError entries come first, then constructs Error::from(result). The From<SimpleConsensusValidationResult> for Error conversion picks the typed primary ConsensusError from position 0, so this correctly surfaces the actionable failure. The comment block (lines 89-115) and the mixed_errors_reorder_real_failure_before_unsupported test pin the behavior.

Downstream consumers that iterate the resulting SimpleConsensusValidationResult.errors for structured diagnostics will now see UnsupportedFeatureError entries after real failures even when DPP originally reported them first. Fine for the typed-error-extraction target; worth noting for future readers who route validation results to error-rendering code paths beyond the typed Err return.

source: ['claude']

Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

I verified the flagged areas against commit 0daa6c5992fc20dc75fe964b93bdb2f33460d98f. The WASM document-transition refactor does not contain the claimed blocking data-corruption bug, but it does leave one real API robustness issue in the rs-sdk builders and one real regression-coverage gap around the new structural FFI hardening.

Reviewed commit: 0daa6c5

🟡 2 suggestion(s)

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/documents/transitions/create.rs`:
- [SUGGESTION] lines 27-48: Public builder fields bypass the new `with_settings()` invariant tracking
  `DocumentCreateTransitionBuilder` now relies on the private `user_fee_increase_explicit` and `state_transition_creation_options_explicit` flags to make `with_settings()` order-independent, but the related semantic fields remain public. Callers can still mutate `settings`, `user_fee_increase`, or `state_transition_creation_options` directly after `new()`, which bypasses the setters that keep those hidden flags in sync. Once that happens, `with_settings()` can silently overwrite a manually assigned value because it has no way to know the field was set explicitly. The same pattern exists in the mirrored replace/delete builders, so the current public API no longer enforces the behavior its new documentation promises.

In `packages/wasm-sdk/tests/unit/prepare-document.spec.ts`:
- [SUGGESTION] lines 67-400: The new structural FFI hardening is not pinned by adversarial regression tests
  The new extraction helpers in `packages/wasm-sdk/src/state_transitions/document.rs` explicitly avoid `DocumentWasm::try_from`, `TokenPaymentInfoWasm::try_from`, and similar pointer-trusting conversions on public JS input. The unit suite covers plain-object success/failure paths, but it never passes forged inputs that spoof `__type` and `__wbg_ptr` for `document`, delete identifier fields, or `tokenPaymentInfo`. Without those adversarial tests, a future refactor could accidentally reintroduce a `__wbg_ptr` dereference on untrusted input while keeping the current test suite green.

Comment on lines +34 to +48
pub settings: Option<PutSettings>,
pub user_fee_increase: Option<UserFeeIncrease>,
pub state_transition_creation_options: Option<StateTransitionCreationOptions>,
/// Tracks whether [`Self::user_fee_increase`] was last set by an
/// explicit [`Self::with_user_fee_increase`] call (as opposed to being
/// extracted from a [`Self::with_settings`] call). Used by
/// [`Self::with_settings`] to honor the "explicit setter wins
/// regardless of order" contract while still letting a second
/// `with_settings` call overwrite a prior settings-derived value.
user_fee_increase_explicit: bool,
/// Tracks whether [`Self::state_transition_creation_options`] was last
/// set by an explicit
/// [`Self::with_state_transition_creation_options`] call; mirrors
/// [`Self::user_fee_increase_explicit`] for the second dedicated field.
state_transition_creation_options_explicit: bool,
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Public builder fields bypass the new with_settings() invariant tracking

DocumentCreateTransitionBuilder now relies on the private user_fee_increase_explicit and state_transition_creation_options_explicit flags to make with_settings() order-independent, but the related semantic fields remain public. Callers can still mutate settings, user_fee_increase, or state_transition_creation_options directly after new(), which bypasses the setters that keep those hidden flags in sync. Once that happens, with_settings() can silently overwrite a manually assigned value because it has no way to know the field was set explicitly. The same pattern exists in the mirrored replace/delete builders, so the current public API no longer enforces the behavior its new documentation promises.

source: ['codex']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-sdk/src/platform/documents/transitions/create.rs`:
- [SUGGESTION] lines 27-48: Public builder fields bypass the new `with_settings()` invariant tracking
  `DocumentCreateTransitionBuilder` now relies on the private `user_fee_increase_explicit` and `state_transition_creation_options_explicit` flags to make `with_settings()` order-independent, but the related semantic fields remain public. Callers can still mutate `settings`, `user_fee_increase`, or `state_transition_creation_options` directly after `new()`, which bypasses the setters that keep those hidden flags in sync. Once that happens, `with_settings()` can silently overwrite a manually assigned value because it has no way to know the field was set explicitly. The same pattern exists in the mirrored replace/delete builders, so the current public API no longer enforces the behavior its new documentation promises.

Comment on lines +67 to +400
describe('prepareDocumentCreate()', () => {
it('rejects a document with no entropy', async () => {
const { signer, identityKey } = buildSigner();
const document = buildDocument();
// Entropy defaults to a generated 32-byte value in the constructor; clear it
// to hit the "must have entropy set" guard in prepare_document_create.
document.entropy = null;

try {
await client.prepareDocumentCreate({ document, identityKey, signer });
expect.fail('expected prepareDocumentCreate to reject without entropy');
} catch (e) {
expect(e).to.be.instanceOf(sdk.WasmSdkError);
expect(e.name).to.equal('InvalidArgument');
expect(e.message).to.match(/entropy/i);
}
});

it('rejects a document whose entropy is not 32 bytes', async () => {
// NOTE: the Document constructor and the entropy setter both reject
// non-32-byte buffers, so the defensive length check inside
// prepare_document_create is unreachable from JS under normal use. We
// assert the outer guard here — that the SDK refuses bad entropy
// *somewhere* before broadcasting — which is the behavior callers care
// about.
try {
const document = buildDocument();
document.entropy = new Uint8Array(16);
expect.fail('expected Document entropy setter to reject a 16-byte buffer');
} catch (e) {
expect(e).to.be.instanceOf(sdk.WasmSdkError);
expect(e.name).to.equal('InvalidArgument');
expect(e.message).to.match(/entropy/i);
}
});

it('rejects a document with revision > INITIAL_REVISION (would silently be a replace)', async () => {
const { signer, identityKey } = buildSigner();
const document = buildDocument({ revision: 2 });

try {
await client.prepareDocumentCreate({ document, identityKey, signer });
expect.fail('expected prepareDocumentCreate to reject revision > 1');
} catch (e) {
expect(e).to.be.instanceOf(sdk.WasmSdkError);
expect(e.name).to.equal('InvalidArgument');
expect(e.message).to.match(/revision/i);
expect(e.message).to.match(/prepareDocumentReplace/);
}
});

it('rejects a document whose id does not match its entropy', async () => {
// The strict create path requires document.id to be derived from
// (dataContractId, ownerId, documentTypeName, entropy) via the v0
// document-id derivation. Manually overwriting `id` after construction
// breaks that invariant and must fail with InvalidArgument before
// any nonce allocation.
const { signer, identityKey } = buildSigner();
const document = buildDocument();
document.id = DUMMY_ID;

try {
await client.prepareDocumentCreate({ document, identityKey, signer });
expect.fail('expected prepareDocumentCreate to reject mismatched id/entropy');
} catch (e) {
expect(e).to.be.instanceOf(sdk.WasmSdkError);
expect(e.name).to.equal('InvalidArgument');
expect(e.message).to.match(/does not match/i);
}
});

it('accepts a plain-object document and applies the same validation', async () => {
// Regression test for the safe structural extraction path. The
// prepare API must accept a plain `{...}` document shape *without*
// dereferencing wasm-bindgen internals — but it must still enforce
// the same revision guard a real `Document` instance hits. Hand the
// SDK a plain object with revision 2 and assert it routes to the
// revision guard, not the `__wbg_ptr` fast path.
const { signer, identityKey } = buildSigner();
const document = {
id: DUMMY_ID,
ownerId: DUMMY_ID_2,
dataContractId: DUMMY_ID,
documentTypeName: 'note',
properties: { message: 'hello' },
revision: 2,
entropy: new Uint8Array(32),
};

try {
await client.prepareDocumentCreate({ document, identityKey, signer });
expect.fail('expected prepareDocumentCreate to reject revision > 1');
} catch (e) {
expect(e).to.be.instanceOf(sdk.WasmSdkError);
expect(e.name).to.equal('InvalidArgument');
expect(e.message).to.match(/revision/i);
expect(e.message).to.match(/prepareDocumentReplace/);
}
});

it('rejects a document with revision 0 (would silently be a replace)', async () => {
// Revision 0 is invalid for *both* create and replace, so the shared
// rejection message must not point users at the sibling API (which
// would also reject). It must say "revision 0 is invalid for both
// create and replace" so callers see the always-invalid value
// explicitly instead of being routed in a loop.
const { signer, identityKey } = buildSigner();
const document = buildDocument({ revision: 0 });

try {
await client.prepareDocumentCreate({ document, identityKey, signer });
expect.fail('expected prepareDocumentCreate to reject revision 0');
} catch (e) {
expect(e).to.be.instanceOf(sdk.WasmSdkError);
expect(e.name).to.equal('InvalidArgument');
expect(e.message).to.match(/revision 0 is invalid for both create and replace/);
expect(e.message).to.not.match(/prepareDocumentReplace/);
expect(e.message).to.not.match(/prepareDocumentCreate/);
}
});
});

describe('prepareDocumentReplace()', () => {
it('rejects a document with no revision', async () => {
const { signer, identityKey } = buildSigner();
const document = buildDocument();
// The Document constructor defaults revision to 1; clear it to exercise
// the "must have a revision set" guard in prepare_document_replace.
document.revision = null;

try {
await client.prepareDocumentReplace({ document, identityKey, signer });
expect.fail('expected prepareDocumentReplace to reject missing revision');
} catch (e) {
expect(e).to.be.instanceOf(sdk.WasmSdkError);
expect(e.name).to.equal('InvalidArgument');
expect(e.message).to.match(/revision/i);
}
});

it('rejects a document with revision 0', async () => {
// Revision 0 is invalid for *both* create and replace, so the shared
// rejection message must not point users at the sibling API (which
// would also reject). It must say "revision 0 is invalid for both
// create and replace" so callers see the always-invalid value
// explicitly instead of being routed in a loop.
const { signer, identityKey } = buildSigner();
const document = buildDocument({ revision: 0 });

try {
await client.prepareDocumentReplace({ document, identityKey, signer });
expect.fail('expected prepareDocumentReplace to reject revision 0');
} catch (e) {
expect(e).to.be.instanceOf(sdk.WasmSdkError);
expect(e.name).to.equal('InvalidArgument');
expect(e.message).to.match(/revision 0 is invalid for both create and replace/);
expect(e.message).to.not.match(/prepareDocumentCreate/);
expect(e.message).to.not.match(/prepareDocumentReplace/);
}
});

it('accepts a plain-object document and applies the same validation', async () => {
// Regression test for the safe structural extraction path. The
// prepare API must accept a plain `{...}` document shape *without*
// dereferencing wasm-bindgen internals — but it must still enforce
// the same revision guard a real `Document` instance hits. Hand the
// SDK a plain object with revision 1 and assert it routes to the
// replace revision guard.
const { signer, identityKey } = buildSigner();
const document = {
id: DUMMY_ID,
ownerId: DUMMY_ID_2,
dataContractId: DUMMY_ID,
documentTypeName: 'note',
properties: { message: 'hello' },
revision: 1,
};

try {
await client.prepareDocumentReplace({ document, identityKey, signer });
expect.fail('expected prepareDocumentReplace to reject revision 1');
} catch (e) {
expect(e).to.be.instanceOf(sdk.WasmSdkError);
expect(e.name).to.equal('InvalidArgument');
expect(e.message).to.match(/revision/i);
expect(e.message).to.match(/prepareDocumentCreate/);
}
});

it('rejects a document with revision 1 (INITIAL_REVISION)', async () => {
const { signer, identityKey } = buildSigner();
const document = buildDocument({ revision: 1 });

try {
await client.prepareDocumentReplace({ document, identityKey, signer });
expect.fail('expected prepareDocumentReplace to reject revision 1');
} catch (e) {
expect(e).to.be.instanceOf(sdk.WasmSdkError);
expect(e.name).to.equal('InvalidArgument');
expect(e.message).to.match(/revision/i);
expect(e.message).to.match(/prepareDocumentCreate/);
}
});
});

describe('prepareDocumentDelete()', () => {
it('rejects when document is missing', async () => {
const { signer, identityKey } = buildSigner();

try {
await client.prepareDocumentDelete({ identityKey, signer });
expect.fail('expected prepareDocumentDelete to reject without document');
} catch (e) {
expect(e).to.be.instanceOf(sdk.WasmSdkError);
expect(e.name).to.equal('InvalidArgument');
expect(e.message).to.match(/document is required/i);
}
});

it('rejects when document is null', async () => {
const { signer, identityKey } = buildSigner();

try {
await client.prepareDocumentDelete({ document: null, identityKey, signer });
expect.fail('expected prepareDocumentDelete to reject null document');
} catch (e) {
expect(e).to.be.instanceOf(sdk.WasmSdkError);
expect(e.name).to.equal('InvalidArgument');
expect(e.message).to.match(/document is required/i);
}
});

it('rejects a plain object with no id', async () => {
const { signer, identityKey } = buildSigner();

try {
await client.prepareDocumentDelete({
document: {
ownerId: DUMMY_ID_2,
dataContractId: DUMMY_ID,
documentTypeName: 'note',
},
identityKey,
signer,
});
expect.fail('expected prepareDocumentDelete to reject missing id');
} catch (e) {
expect(e).to.be.instanceOf(sdk.WasmSdkError);
expect(e.name).to.equal('InvalidArgument');
expect(e.message).to.match(/id/i);
}
});

it('rejects a plain object with no ownerId', async () => {
const { signer, identityKey } = buildSigner();

try {
await client.prepareDocumentDelete({
document: {
id: DUMMY_ID,
dataContractId: DUMMY_ID,
documentTypeName: 'note',
},
identityKey,
signer,
});
expect.fail('expected prepareDocumentDelete to reject missing ownerId');
} catch (e) {
expect(e).to.be.instanceOf(sdk.WasmSdkError);
expect(e.name).to.equal('InvalidArgument');
expect(e.message).to.match(/ownerId/i);
}
});

it('rejects a plain object with no dataContractId', async () => {
const { signer, identityKey } = buildSigner();

try {
await client.prepareDocumentDelete({
document: {
id: DUMMY_ID,
ownerId: DUMMY_ID_2,
documentTypeName: 'note',
},
identityKey,
signer,
});
expect.fail('expected prepareDocumentDelete to reject missing dataContractId');
} catch (e) {
expect(e).to.be.instanceOf(sdk.WasmSdkError);
expect(e.name).to.equal('InvalidArgument');
expect(e.message).to.match(/dataContractId/i);
}
});

it('rejects a plain object with no documentTypeName', async () => {
const { signer, identityKey } = buildSigner();

try {
await client.prepareDocumentDelete({
document: {
id: DUMMY_ID,
ownerId: DUMMY_ID_2,
dataContractId: DUMMY_ID,
},
identityKey,
signer,
});
expect.fail('expected prepareDocumentDelete to reject missing documentTypeName');
} catch (e) {
expect(e).to.be.instanceOf(sdk.WasmSdkError);
expect(e.name).to.equal('InvalidArgument');
expect(e.message).to.match(/documentTypeName/i);
}
});

it('rejects a Document instance when identityKey has the wrong shape', async () => {
const { signer } = buildSigner();
const document = buildDocument({ revision: 1 });

try {
await client.prepareDocumentDelete({
document,
identityKey: {},
signer,
});
expect.fail('expected prepareDocumentDelete to reject invalid identityKey');
} catch (e) {
expect(e).to.be.instanceOf(sdk.WasmSdkError);
expect(e.name).to.equal('InvalidArgument');
expect(e.message).to.match(/identityKey/i);
}
});
});
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: The new structural FFI hardening is not pinned by adversarial regression tests

The new extraction helpers in packages/wasm-sdk/src/state_transitions/document.rs explicitly avoid DocumentWasm::try_from, TokenPaymentInfoWasm::try_from, and similar pointer-trusting conversions on public JS input. The unit suite covers plain-object success/failure paths, but it never passes forged inputs that spoof __type and __wbg_ptr for document, delete identifier fields, or tokenPaymentInfo. Without those adversarial tests, a future refactor could accidentally reintroduce a __wbg_ptr dereference on untrusted input while keeping the current test suite green.

source: ['codex']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/wasm-sdk/tests/unit/prepare-document.spec.ts`:
- [SUGGESTION] lines 67-400: The new structural FFI hardening is not pinned by adversarial regression tests
  The new extraction helpers in `packages/wasm-sdk/src/state_transitions/document.rs` explicitly avoid `DocumentWasm::try_from`, `TokenPaymentInfoWasm::try_from`, and similar pointer-trusting conversions on public JS input. The unit suite covers plain-object success/failure paths, but it never passes forged inputs that spoof `__type` and `__wbg_ptr` for `document`, delete identifier fields, or `tokenPaymentInfo`. Without those adversarial tests, a future refactor could accidentally reintroduce a `__wbg_ptr` dereference on untrusted input while keeping the current test suite green.

Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

I verified the reported WASM interop concern against 0d84e9b62b35b320fb4840ecd1f1076151b9739e. The cited identityKey/signer conversions do exist, but they are a documented, intentional trust boundary that already applies to both the one-shot and prepare document APIs in this module; this PR does not introduce a new dereference path or broaden that contract. I did not confirm any PR-correct blocking or suggestion-level findings.

Reviewed commit: 0d84e9b

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(sdk): high-level document APIs lack idempotent retry — timeout causes duplicate state transitions

5 participants